From 2132e7506dffec640b446e9f9decf091b2980f54 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Tue, 15 Oct 2024 20:07:05 +0300 Subject: [PATCH 01/31] [Cloud Security] Update wiz version callout (#196316) --- .../cloud_posture_third_party_support_callout.test.tsx | 6 +++--- .../cloud_posture_third_party_support_callout.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.test.tsx index 7b238ef49fa2e..b0e5cda02bfdb 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.test.tsx @@ -28,14 +28,14 @@ describe('CloudPostureThirdPartySupportCallout', () => { render(); - expect(screen.getByText(/New! Starting from version 1.9/)).toBeInTheDocument(); + expect(screen.getByText(/New! Starting from version 2.0/)).toBeInTheDocument(); }); it('does not render callout when package is not wiz', () => { const nonWizPackageInfo = { name: 'other' } as PackageInfo; render(); - expect(screen.queryByText(/New! Starting from version 1.9/)).not.toBeInTheDocument(); + expect(screen.queryByText(/New! Starting from version 2.0/)).not.toBeInTheDocument(); }); it('does not render callout when it has been dismissed', () => { @@ -43,6 +43,6 @@ describe('CloudPostureThirdPartySupportCallout', () => { render(); - expect(screen.queryByText(/New! Starting from version 1.9/)).not.toBeInTheDocument(); + expect(screen.queryByText(/New! Starting from version 2.0/)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.tsx index 6bd4197dc267e..cd0a11b726fdf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/cloud_posture_third_party_support_callout.tsx @@ -33,7 +33,7 @@ export const CloudPostureThirdPartySupportCallout = ({ iconType="cheer" title={i18n.translate('xpack.fleet.epm.wizIntegration.newFeaturesCallout', { defaultMessage: - 'New! Starting from version 1.9, ingest vulnerability and misconfiguration findings from Wiz into Elastic. Leverage out-of-the-box contextual investigation and threat-hunting workflows.', + 'New! Starting from version 2.0, ingest vulnerability and misconfiguration findings from Wiz into Elastic. Leverage out-of-the-box contextual investigation and threat-hunting workflows.', })} /> From 07642611899034fd4d9ab8362b6303405871c055 Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Tue, 15 Oct 2024 19:09:22 +0200 Subject: [PATCH 02/31] [Security Solution][Notes] - fix incorrect get_notes api for documentIds and savedObjectIds query parameters and adding api integration tests (#196225) --- .../lib/timeline/routes/notes/get_notes.ts | 104 +++--- .../trial_license_complete_tier/helpers.ts | 40 +- .../trial_license_complete_tier/notes.ts | 343 +++++++++++++++++- 3 files changed, 440 insertions(+), 47 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 0f3440d8ed13a..925379baedad5 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -9,6 +9,8 @@ import type { IKibanaResponse } from '@kbn/core-http-server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server'; +import { nodeBuilder } from '@kbn/es-query'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants'; @@ -43,78 +45,90 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { uiSettings: { client: uiSettingsClient }, } = await frameworkRequest.context.core; const maxUnassociatedNotes = await uiSettingsClient.get(MAX_UNASSOCIATED_NOTES); + + // if documentIds is provided, we will search for all the notes associated with the documentIds const documentIds = queryParams.documentIds ?? null; - const savedObjectIds = queryParams.savedObjectIds ?? null; if (documentIds != null) { + // search for multiple document ids (like retrieving all the notes for all the alerts within a table) if (Array.isArray(documentIds)) { - const docIdSearchString = documentIds?.join(' | '); - const options = { + const options: SavedObjectsFindOptions = { type: noteSavedObjectType, - search: docIdSearchString, + filter: nodeBuilder.or( + documentIds.map((documentId: string) => + nodeBuilder.is(`${noteSavedObjectType}.attributes.eventId`, documentId) + ) + ), page: 1, perPage: maxUnassociatedNotes, }; const res = await getAllSavedNote(frameworkRequest, options); const body: GetNotesResponse = res ?? {}; return response.ok({ body }); - } else { - const options = { - type: noteSavedObjectType, - search: documentIds, - page: 1, - perPage: maxUnassociatedNotes, - }; - const res = await getAllSavedNote(frameworkRequest, options); - return response.ok({ body: res ?? {} }); } - } else if (savedObjectIds != null) { + + // searching for all the notes associated with a specific document id + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + filter: nodeBuilder.is(`${noteSavedObjectType}.attributes.eventId`, documentIds), + page: 1, + perPage: maxUnassociatedNotes, + }; + const res = await getAllSavedNote(frameworkRequest, options); + return response.ok({ body: res ?? {} }); + } + + // if savedObjectIds is provided, we will search for all the notes associated with the savedObjectIds + const savedObjectIds = queryParams.savedObjectIds ?? null; + if (savedObjectIds != null) { + // search for multiple saved object ids if (Array.isArray(savedObjectIds)) { - const soIdSearchString = savedObjectIds?.join(' | '); - const options = { + const options: SavedObjectsFindOptions = { type: noteSavedObjectType, - hasReference: { + hasReference: savedObjectIds.map((savedObjectId: string) => ({ type: timelineSavedObjectType, - id: soIdSearchString, - }, + id: savedObjectId, + })), page: 1, perPage: maxUnassociatedNotes, }; const res = await getAllSavedNote(frameworkRequest, options); const body: GetNotesResponse = res ?? {}; return response.ok({ body }); - } else { - const options = { - type: noteSavedObjectType, - hasReference: { - type: timelineSavedObjectType, - id: savedObjectIds, - }, - perPage: maxUnassociatedNotes, - }; - const res = await getAllSavedNote(frameworkRequest, options); - const body: GetNotesResponse = res ?? {}; - return response.ok({ body }); } - } else { - const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; - const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1; - const search = queryParams?.search ?? undefined; - const sortField = queryParams?.sortField ?? undefined; - const sortOrder = (queryParams?.sortOrder as SortOrder) ?? undefined; - const filter = queryParams?.filter; - const options = { + + // searching for all the notes associated with a specific saved object id + const options: SavedObjectsFindOptions = { type: noteSavedObjectType, - perPage, - page, - search, - sortField, - sortOrder, - filter, + hasReference: { + type: timelineSavedObjectType, + id: savedObjectIds, + }, + perPage: maxUnassociatedNotes, }; const res = await getAllSavedNote(frameworkRequest, options); const body: GetNotesResponse = res ?? {}; return response.ok({ body }); } + + // retrieving all the notes following the query parameters + const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; + const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1; + const search = queryParams?.search ?? undefined; + const sortField = queryParams?.sortField ?? undefined; + const sortOrder = (queryParams?.sortOrder as SortOrder) ?? undefined; + const filter = queryParams?.filter; + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + perPage, + page, + search, + sortField, + sortOrder, + filter, + }; + const res = await getAllSavedNote(frameworkRequest, options); + const body: GetNotesResponse = res ?? {}; + return response.ok({ body }); } catch (err) { const error = transformError(err); const siemResponse = buildSiemResponse(response); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts index 9f40373976c28..a5944dc8c6149 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts @@ -7,7 +7,11 @@ import type SuperTest from 'supertest'; import { v4 as uuidv4 } from 'uuid'; -import { TimelineTypeEnum } from '@kbn/security-solution-plugin/common/api/timeline'; +import { BareNote, TimelineTypeEnum } from '@kbn/security-solution-plugin/common/api/timeline'; +import { NOTE_URL } from '@kbn/security-solution-plugin/common/constants'; +import type { Client } from '@elastic/elasticsearch'; +import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { noteSavedObjectType } from '@kbn/security-solution-plugin/server/lib/timeline/saved_object_mappings'; export const createBasicTimeline = async (supertest: SuperTest.Agent, titleToSaved: string) => await supertest @@ -38,3 +42,37 @@ export const createBasicTimelineTemplate = async ( timelineType: TimelineTypeEnum.template, }, }); + +export const deleteAllNotes = async (es: Client): Promise => { + await es.deleteByQuery({ + index: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, + q: `type:${noteSavedObjectType}`, + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + +export const createNote = async ( + supertest: SuperTest.Agent, + note: { + documentId?: string; + savedObjectId?: string; + user?: string; + text: string; + } +) => + await supertest + .patch(NOTE_URL) + .set('kbn-xsrf', 'true') + .send({ + note: { + eventId: note.documentId || '', + timelineId: note.savedObjectId || '', + created: Date.now(), + createdBy: note.user || 'elastic', + updated: Date.now(), + updatedBy: note.user || 'elastic', + note: note.text, + } as BareNote, + }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts index 666e36325fd7f..dabb453f80158 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts @@ -6,7 +6,9 @@ */ import expect from '@kbn/expect'; - +import { v4 as uuidv4 } from 'uuid'; +import { Note } from '@kbn/security-solution-plugin/common/api/timeline'; +import { createNote, deleteAllNotes } from './helpers'; import { FtrProviderContext } from '../../../../../api_integration/ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { @@ -14,6 +16,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('Note - Saved Objects', () => { + const es = getService('es'); + before(() => kibanaServer.savedObjects.cleanStandardList()); after(() => kibanaServer.savedObjects.cleanStandardList()); @@ -70,5 +74,342 @@ export default function ({ getService }: FtrProviderContext) { expect(responseToTest.body.data!.persistNote.note.version).to.not.be.eql(version); }); }); + + describe('get notes', () => { + beforeEach(async () => { + await deleteAllNotes(es); + }); + + const eventId1 = uuidv4(); + const eventId2 = uuidv4(); + const eventId3 = uuidv4(); + const timelineId1 = uuidv4(); + const timelineId2 = uuidv4(); + const timelineId3 = uuidv4(); + + it('should retrieve all the notes for a document id', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { documentId: eventId2, text: 'associated with event-2 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + savedObjectId: timelineId2, + text: 'associated with timeline-2 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { + documentId: eventId2, + savedObjectId: timelineId2, + text: 'associated with event-2 and timeline-2', + }), + createNote(supertest, { text: 'associated with nothing' }), + createNote(supertest, { + text: `associated with nothing but has ${eventId1} in the text`, + }), + ]); + + const response = await supertest + .get(`/api/note?documentIds=${eventId1}`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(2); + notes.forEach((note: Note) => expect(note.eventId).to.be(eventId1)); + }); + + it('should retrieve all the notes for multiple document ids', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { documentId: eventId2, text: 'associated with event-2 only' }), + createNote(supertest, { documentId: eventId3, text: 'associated with event-3 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + savedObjectId: timelineId2, + text: 'associated with timeline-2 only', + }), + createNote(supertest, { + savedObjectId: timelineId3, + text: 'associated with timeline-3 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { + documentId: eventId2, + savedObjectId: timelineId2, + text: 'associated with event-2 and timeline-2', + }), + createNote(supertest, { + documentId: eventId3, + savedObjectId: timelineId3, + text: 'associated with event-3 and timeline-3', + }), + createNote(supertest, { text: 'associated with nothing' }), + createNote(supertest, { + text: `associated with nothing but has ${eventId1} in the text`, + }), + createNote(supertest, { + text: `associated with nothing but has ${eventId2} in the text`, + }), + createNote(supertest, { + text: `associated with nothing but has ${eventId3} in the text`, + }), + ]); + + const response = await supertest + .get(`/api/note?documentIds=${eventId1}&documentIds=${eventId2}`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(4); + notes.forEach((note: Note) => { + expect(note.eventId).to.not.be(eventId3); + expect(note.eventId).to.not.be(''); + }); + }); + + it('should retrieve all the notes for a saved object id', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { documentId: eventId2, text: 'associated with event-2 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + savedObjectId: timelineId2, + text: 'associated with timeline-2 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { + documentId: eventId2, + savedObjectId: timelineId2, + text: 'associated with event-2 and timeline-2', + }), + createNote(supertest, { text: 'associated with nothing' }), + createNote(supertest, { + text: `associated with nothing but has ${timelineId1} in the text`, + }), + ]); + + const response = await supertest + .get(`/api/note?savedObjectIds=${timelineId1}`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(2); + notes.forEach((note: Note) => expect(note.timelineId).to.be(timelineId1)); + }); + + it('should retrieve all the notes for multiple saved object ids', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { documentId: eventId2, text: 'associated with event-2 only' }), + createNote(supertest, { documentId: eventId3, text: 'associated with event-3 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + savedObjectId: timelineId2, + text: 'associated with timeline-2 only', + }), + createNote(supertest, { + savedObjectId: timelineId3, + text: 'associated with timeline-3 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { + documentId: eventId2, + savedObjectId: timelineId2, + text: 'associated with event-2 and timeline-2', + }), + createNote(supertest, { + documentId: eventId3, + savedObjectId: timelineId3, + text: 'associated with event-3 and timeline-3', + }), + createNote(supertest, { text: 'associated with nothing' }), + createNote(supertest, { + text: `associated with nothing but has ${timelineId1} in the text`, + }), + createNote(supertest, { + text: `associated with nothing but has ${timelineId2} in the text`, + }), + createNote(supertest, { + text: `associated with nothing but has ${timelineId3} in the text`, + }), + ]); + + const response = await supertest + .get(`/api/note?savedObjectIds=${timelineId1}&savedObjectIds=${timelineId2}`) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(4); + notes.forEach((note: Note) => { + expect(note.timelineId).to.not.be(timelineId3); + expect(note.timelineId).to.not.be(''); + }); + }); + + it('should retrieve all notes without any query params', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { text: 'associated with nothing' }), + ]); + + const response = await supertest + .get('/api/note') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(4); + }); + + it('should retrieve notes considering perPage query parameter', async () => { + await Promise.all([ + createNote(supertest, { text: 'first note' }), + createNote(supertest, { text: 'second note' }), + createNote(supertest, { text: 'third note' }), + ]); + + const response = await supertest + .get('/api/note?perPage=1') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(3); + expect(notes.length).to.be(1); + }); + + it('should retrieve considering page query parameter', async () => { + await createNote(supertest, { text: 'first note' }); + await createNote(supertest, { text: 'second note' }); + await createNote(supertest, { text: 'third note' }); + + const response = await supertest + .get('/api/note?perPage=1&page=2') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(3); + expect(notes.length).to.be(1); + expect(notes[0].note).to.be('second note'); + }); + + it('should retrieve considering search query parameter', async () => { + await Promise.all([ + createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }), + createNote(supertest, { + savedObjectId: timelineId1, + text: 'associated with timeline-1 only', + }), + createNote(supertest, { + documentId: eventId1, + savedObjectId: timelineId1, + text: 'associated with event-1 and timeline-1', + }), + createNote(supertest, { text: 'associated with nothing' }), + ]); + + const response = await supertest + .get('/api/note?search=event') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(2); + }); + + // TODO why can't we sort on every field? (I tested for the note field (or a random field like abc) and the endpoint crashes) + it('should retrieve considering sortField query parameters', async () => { + await Promise.all([ + createNote(supertest, { documentId: '1', text: 'note 1' }), + createNote(supertest, { documentId: '2', text: 'note 2' }), + createNote(supertest, { documentId: '3', text: 'note 3' }), + ]); + + const response = await supertest + .get('/api/note?sortField=eventId') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(3); + expect(notes[0].eventId).to.be('1'); + expect(notes[1].eventId).to.be('2'); + expect(notes[2].eventId).to.be('3'); + }); + + it('should retrieve considering sortOrder query parameters', async () => { + await Promise.all([ + createNote(supertest, { documentId: '1', text: 'note 1' }), + createNote(supertest, { documentId: '2', text: 'note 2' }), + createNote(supertest, { documentId: '3', text: 'note 3' }), + ]); + + const response = await supertest + .get('/api/note?sortField=eventId&sortOrder=desc') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount, notes } = response.body; + + expect(totalCount).to.be(3); + expect(notes[0].eventId).to.be('3'); + expect(notes[1].eventId).to.be('2'); + expect(notes[2].eventId).to.be('1'); + }); + + // TODO should add more tests for the filter query parameter (I don't know how it's supposed to work) + + // TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values + }); }); } From 9512f6c26fbac59b8b8d7390dc28da930e42f181 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 15 Oct 2024 10:18:41 -0700 Subject: [PATCH 03/31] [UII] Support content packages in UI (#195831) ## Summary Resolves #192484. This PR adds support for content packages in UI. When a package is of `type: content`: - `Content only` badge is shown on its card in Integrations list, and on header of its details page - `Add integration` button is replaced by `Install assets` button in header - References to agent policies are hidden - Package policy service throws error if attempting to create or bulk create policies for content packages image image ## How to test The only current content package is `kubernetes_otel`. You will need to bump up the max allowed spec version and search with beta (prerelease) packages enabled to find it: ``` xpack.fleet.internal.registry.spec.max: '3.4' ``` Test UI scenarios as above. The API can be tested by running: ``` POST kbn:/api/fleet/package_policies { "policy_ids": [ "" ], "package": { "name": "kubernetes_otel", "version": "0.0.2" }, "name": "kubernetes_otel-1", "description": "", "namespace": "", "inputs": {} } ``` ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../single_page_layout/hooks/form.tsx | 2 +- .../sections/epm/components/package_card.tsx | 23 ++++- .../sections/epm/screens/detail/index.tsx | 85 +++++++++++------- .../settings/confirm_package_install.tsx | 12 ++- .../detail/settings/install_button.tsx | 25 ++++-- .../epm/screens/detail/settings/settings.tsx | 32 ------- .../detail/settings/uninstall_button.tsx | 15 +++- .../sections/epm/screens/home/card_utils.tsx | 4 +- .../plugins/fleet/server/errors/handlers.ts | 4 + x-pack/plugins/fleet/server/errors/index.ts | 1 + .../services/package_policies/utils.test.ts | 35 ++++++-- .../server/services/package_policies/utils.ts | 21 ++++- .../fleet/server/services/package_policy.ts | 24 ++++- .../good_content/0.1.0/changelog.yml | 5 ++ .../good_content/0.1.0/docs/README.md | 1 + .../good_content/0.1.0/img/kibana-system.png | Bin 0 -> 205298 bytes .../good_content/0.1.0/img/system.svg | 1 + .../good_content/0.1.0/manifest.yml | 32 +++++++ .../good_content/0.1.0/validation.yml | 3 + .../apis/package_policy/create.ts | 18 ++++ 20 files changed, 252 insertions(+), 91 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/changelog.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/kibana-system.png create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/system.svg create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/validation.yml diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx index 2bae962f48e7c..0c3f54d9e5dff 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx @@ -391,7 +391,7 @@ export function useOnSubmit({ // Check if agentless is configured in ESS and Serverless until Agentless API migrates to Serverless const isAgentlessConfigured = - isAgentlessAgentPolicy(createdPolicy) || isAgentlessPackagePolicy(data!.item); + isAgentlessAgentPolicy(createdPolicy) || (data && isAgentlessPackagePolicy(data.item)); // Removing this code will disabled the Save and Continue button. We need code below update form state and trigger correct modal depending on agent count if (hasFleetAddAgentsPrivileges && !isAgentlessConfigured) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index 31213e5f9554a..52a3a90ae641e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -57,6 +57,7 @@ export function PackageCard({ name, title, version, + type, icons, integration, url, @@ -78,7 +79,6 @@ export function PackageCard({ maxCardHeight, }: PackageCardProps) { let releaseBadge: React.ReactNode | null = null; - if (release && release !== 'ga') { releaseBadge = ( @@ -108,7 +108,6 @@ export function PackageCard({ } let hasDeferredInstallationsBadge: React.ReactNode | null = null; - if (isReauthorizationRequired && showLabels) { hasDeferredInstallationsBadge = ( @@ -127,7 +126,6 @@ export function PackageCard({ } let updateAvailableBadge: React.ReactNode | null = null; - if (isUpdateAvailable && showLabels) { updateAvailableBadge = ( @@ -145,7 +143,6 @@ export function PackageCard({ } let collectionButton: React.ReactNode | null = null; - if (isCollectionCard) { collectionButton = ( @@ -163,6 +160,23 @@ export function PackageCard({ ); } + let contentBadge: React.ReactNode | null = null; + if (type === 'content') { + contentBadge = ( + + + + + + + + + ); + } + const { application } = useStartServices(); const isGuidedOnboardingActive = useIsGuidedOnboardingActive(name); @@ -235,6 +249,7 @@ export function PackageCard({ {showLabels && extraLabelsBadges ? extraLabelsBadges : null} {verifiedBadge} {updateAvailableBadge} + {contentBadge} {releaseBadge} {hasDeferredInstallationsBadge} {collectionButton} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 51f54fc26c9cb..9a707500bb03d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -90,6 +90,7 @@ import { Configs } from './configs'; import './index.scss'; import type { InstallPkgRouteOptions } from './utils/get_install_route_options'; +import { InstallButton } from './settings/install_button'; export type DetailViewPanelName = | 'overview' @@ -362,13 +363,23 @@ export function Detail() { - - - {i18n.translate('xpack.fleet.epm.elasticAgentBadgeLabel', { - defaultMessage: 'Elastic Agent', - })} - - + {packageInfo?.type === 'content' ? ( + + + {i18n.translate('xpack.fleet.epm.contentPackageBadgeLabel', { + defaultMessage: 'Content only', + })} + + + ) : ( + + + {i18n.translate('xpack.fleet.epm.elasticAgentBadgeLabel', { + defaultMessage: 'Elastic Agent', + })} + + + )} {packageInfo?.release && packageInfo.release !== 'ga' ? ( @@ -520,7 +531,7 @@ export function Detail() { ), }, - ...(isInstalled + ...(isInstalled && packageInfo.type !== 'content' ? [ { isDivider: true }, { @@ -532,31 +543,37 @@ export function Detail() { }, ] : []), - { isDivider: true }, - { - content: ( - - - - ), - }, + ...(packageInfo.type === 'content' + ? !isInstalled + ? [{ isDivider: true }, { content: }] + : [] // if content package is already installed, don't show install button in header + : [ + { isDivider: true }, + { + content: ( + + + + ), + }, + ]), ].map((item, index) => ( {item.isDivider ?? false ? ( @@ -619,7 +636,7 @@ export function Detail() { }, ]; - if (canReadIntegrationPolicies && isInstalled) { + if (canReadIntegrationPolicies && isInstalled && packageInfo.type !== 'content') { tabs.push({ id: 'policies', name: ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/confirm_package_install.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/confirm_package_install.tsx index 31e4fc32233e9..5fdcdc49223e1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/confirm_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/confirm_package_install.tsx @@ -14,9 +14,13 @@ interface ConfirmPackageInstallProps { onConfirm: () => void; packageName: string; numOfAssets: number; + numOfTransformAssets: number; } + +import { TransformInstallWithCurrentUserPermissionCallout } from '../../../../../../../components/transform_install_as_current_user_callout'; + export const ConfirmPackageInstall = (props: ConfirmPackageInstallProps) => { - const { onCancel, onConfirm, packageName, numOfAssets } = props; + const { onCancel, onConfirm, packageName, numOfAssets, numOfTransformAssets } = props; return ( { /> } /> + {numOfTransformAssets > 0 ? ( + <> + + + + ) : null}

& { + +type InstallationButtonProps = Pick & { disabled?: boolean; dryRunData?: UpgradePackagePolicyDryRunResponse | null; isUpgradingPackagePolicies?: boolean; latestVersion?: string; - numOfAssets: number; packagePolicyIds?: string[]; setIsUpgradingPackagePolicies?: React.Dispatch>; }; export function InstallButton(props: InstallationButtonProps) { - const { name, numOfAssets, title, version } = props; + const { name, title, version, assets } = props; + const canInstallPackages = useAuthz().integrations.installPackages; const installPackage = useInstallPackage(); const getPackageInstallStatus = useGetPackageInstallStatus(); const { status: installationStatus } = getPackageInstallStatus(name); + const numOfAssets = Object.entries(assets).reduce( + (acc, [serviceName, serviceNameValue]) => + acc + + Object.entries(serviceNameValue || {}).reduce( + (acc2, [assetName, assetNameValue]) => acc2 + assetNameValue.length, + 0 + ), + 0 + ); + const numOfTransformAssets = getNumTransformAssets(assets); + const isInstalling = installationStatus === InstallStatus.installing; const [isInstallModalVisible, setIsInstallModalVisible] = useState(false); const toggleInstallModal = useCallback(() => { @@ -44,6 +58,7 @@ export function InstallButton(props: InstallationButtonProps) { const installModal = ( = memo( const isUpdating = installationStatus === InstallStatus.installing && installedVersion; - const { numOfAssets, numTransformAssets } = useMemo( - () => ({ - numTransformAssets: getNumTransformAssets(packageInfo.assets), - numOfAssets: Object.entries(packageInfo.assets).reduce( - (acc, [serviceName, serviceNameValue]) => - acc + - Object.entries(serviceNameValue || {}).reduce( - (acc2, [assetName, assetNameValue]) => acc2 + assetNameValue.length, - 0 - ), - 0 - ), - }), - [packageInfo.assets] - ); - return ( <> @@ -365,15 +344,6 @@ export const SettingsPage: React.FC = memo( - - {numTransformAssets > 0 ? ( - <> - - - - ) : null}

= memo(

@@ -418,7 +387,6 @@ export const SettingsPage: React.FC = memo(
diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/uninstall_button.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/uninstall_button.tsx index df472c765c09a..aba40aeba2397 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/uninstall_button.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/uninstall_button.tsx @@ -16,17 +16,16 @@ import { useAuthz, useGetPackageInstallStatus, useUninstallPackage } from '../.. import { ConfirmPackageUninstall } from './confirm_package_uninstall'; -interface UninstallButtonProps extends Pick { +interface UninstallButtonProps extends Pick { disabled?: boolean; latestVersion?: string; - numOfAssets: number; } export const UninstallButton: React.FunctionComponent = ({ disabled = false, latestVersion, name, - numOfAssets, + assets, title, version, }) => { @@ -38,6 +37,16 @@ export const UninstallButton: React.FunctionComponent = ({ const [isUninstallModalVisible, setIsUninstallModalVisible] = useState(false); + const numOfAssets = Object.entries(assets).reduce( + (acc, [serviceName, serviceNameValue]) => + acc + + Object.entries(serviceNameValue || {}).reduce( + (acc2, [assetName, assetNameValue]) => acc2 + assetNameValue.length, + 0 + ), + 0 + ); + const handleClickUninstall = useCallback(() => { uninstallPackage({ name, version, title, redirectToVersion: latestVersion ?? version }); setIsUninstallModalVisible(false); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx index 5a97d1c61df6f..19f4d8740b75d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/card_utils.tsx @@ -65,6 +65,7 @@ export interface IntegrationCardItem { titleLineClamp?: number; url: string; version: string; + type?: string; } export const mapToCard = ({ @@ -114,7 +115,7 @@ export const mapToCard = ({ const release: IntegrationCardReleaseLabel = getPackageReleaseLabel(version); let extraLabelsBadges: React.ReactNode[] | undefined; - if (item.type === 'integration') { + if (item.type === 'integration' || item.type === 'content') { extraLabelsBadges = getIntegrationLabels(item); } @@ -128,6 +129,7 @@ export const mapToCard = ({ integration: 'integration' in item ? item.integration || '' : '', name: 'name' in item ? item.name : item.id, version, + type: item.type, release, categories: ((item.categories || []) as string[]).filter((c: string) => !!c), isReauthorizationRequired, diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index d8971948397d3..31e4b9d6704c7 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -45,6 +45,7 @@ import { PackageSavedObjectConflictError, FleetTooManyRequestsError, AgentlessPolicyExistsRequestError, + PackagePolicyContentPackageError, } from '.'; type IngestErrorHandler = ( @@ -84,6 +85,9 @@ const getHTTPResponseCode = (error: FleetError): number => { if (error instanceof PackagePolicyRequestError) { return 400; } + if (error instanceof PackagePolicyContentPackageError) { + return 400; + } // Unauthorized if (error instanceof FleetUnauthorizedError) { return 403; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 6782b8122a552..de528f082c096 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -73,6 +73,7 @@ export class BundledPackageLocationNotFoundError extends FleetError {} export class PackagePolicyRequestError extends FleetError {} export class PackagePolicyMultipleAgentPoliciesError extends FleetError {} export class PackagePolicyOutputError extends FleetError {} +export class PackagePolicyContentPackageError extends FleetError {} export class EnrollmentKeyNameExistsError extends FleetError {} export class HostedAgentPolicyRestrictionRelatedError extends FleetError { diff --git a/x-pack/plugins/fleet/server/services/package_policies/utils.test.ts b/x-pack/plugins/fleet/server/services/package_policies/utils.test.ts index 9d68dde10a13e..7075990620ef5 100644 --- a/x-pack/plugins/fleet/server/services/package_policies/utils.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies/utils.test.ts @@ -153,16 +153,41 @@ describe('Package Policy Utils', () => { ).rejects.toThrowError('Output type "kafka" is not usable with package "apm"'); }); - it('should not throw if valid license and valid output_id is provided', async () => { + it('should throw if content package is being used', async () => { jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); jest .spyOn(outputService, 'get') .mockResolvedValueOnce({ id: 'es-output', type: 'elasticsearch' } as any); await expect( - preflightCheckPackagePolicy(soClient, { - ...testPolicy, - output_id: 'es-output', - }) + preflightCheckPackagePolicy( + soClient, + { + ...testPolicy, + output_id: 'es-output', + }, + { + type: 'content', + } + ) + ).rejects.toThrowError('Cannot create policy for content only packages'); + }); + + it('should not throw if valid license and valid output_id is provided and is not content package', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + jest + .spyOn(outputService, 'get') + .mockResolvedValueOnce({ id: 'es-output', type: 'elasticsearch' } as any); + await expect( + preflightCheckPackagePolicy( + soClient, + { + ...testPolicy, + output_id: 'es-output', + }, + { + type: 'integration', + } + ) ).resolves.not.toThrow(); }); }); diff --git a/x-pack/plugins/fleet/server/services/package_policies/utils.ts b/x-pack/plugins/fleet/server/services/package_policies/utils.ts index 5c19345a58f79..ef59c643a8b35 100644 --- a/x-pack/plugins/fleet/server/services/package_policies/utils.ts +++ b/x-pack/plugins/fleet/server/services/package_policies/utils.ts @@ -13,8 +13,17 @@ import { LICENCE_FOR_MULTIPLE_AGENT_POLICIES, } from '../../../common/constants'; import { getAllowedOutputTypesForIntegration } from '../../../common/services/output_helpers'; -import type { PackagePolicy, NewPackagePolicy, PackagePolicySOAttributes } from '../../types'; -import { PackagePolicyMultipleAgentPoliciesError, PackagePolicyOutputError } from '../../errors'; +import type { + PackagePolicy, + NewPackagePolicy, + PackagePolicySOAttributes, + PackageInfo, +} from '../../types'; +import { + PackagePolicyMultipleAgentPoliciesError, + PackagePolicyOutputError, + PackagePolicyContentPackageError, +} from '../../errors'; import { licenseService } from '../license'; import { outputService } from '../output'; import { appContextService } from '../app_context'; @@ -35,8 +44,14 @@ export const mapPackagePolicySavedObjectToPackagePolicy = ({ export async function preflightCheckPackagePolicy( soClient: SavedObjectsClientContract, - packagePolicy: PackagePolicy | NewPackagePolicy + packagePolicy: PackagePolicy | NewPackagePolicy, + packageInfo?: Pick ) { + // Package policies cannot be created for content type packages + if (packageInfo?.type === 'content') { + throw new PackagePolicyContentPackageError('Cannot create policy for content only packages'); + } + // If package policy has multiple agent policies IDs, or no agent policies (orphaned integration policy) // check if user can use multiple agent policies feature const { canUseReusablePolicies, errorMessage: canUseMultipleAgentPoliciesErrorMessage } = diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 86d81f3df9b1a..0cf4345235d54 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -233,6 +233,17 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } const savedObjectType = await getPackagePolicySavedObjectType(); + const basePkgInfo = + options?.packageInfo ?? + (packagePolicy.package + ? await getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + ignoreUnverified: true, + prerelease: true, + }) + : undefined); auditLoggingService.writeCustomSoAuditLog({ action: 'create', @@ -245,7 +256,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { logger.debug(`Creating new package policy`); this.keepPolicyIdInSync(packagePolicy); - await preflightCheckPackagePolicy(soClient, packagePolicy); + await preflightCheckPackagePolicy(soClient, packagePolicy, basePkgInfo); let enrichedPackagePolicy = await packagePolicyService.runExternalCallbacks( 'packagePolicyCreate', @@ -448,6 +459,15 @@ class PackagePolicyClientImpl implements PackagePolicyClient { }> { const savedObjectType = await getPackagePolicySavedObjectType(); for (const packagePolicy of packagePolicies) { + const basePkgInfo = packagePolicy.package + ? await getPackageInfo({ + savedObjectsClient: soClient, + pkgName: packagePolicy.package.name, + pkgVersion: packagePolicy.package.version, + ignoreUnverified: true, + prerelease: true, + }) + : undefined; if (!packagePolicy.id) { packagePolicy.id = SavedObjectsUtils.generateId(); } @@ -458,7 +478,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient { }); this.keepPolicyIdInSync(packagePolicy); - await preflightCheckPackagePolicy(soClient, packagePolicy); + await preflightCheckPackagePolicy(soClient, packagePolicy, basePkgInfo); } const agentPolicyIds = new Set(packagePolicies.flatMap((pkgPolicy) => pkgPolicy.policy_ids)); diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/changelog.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/changelog.yml new file mode 100644 index 0000000000000..c8397a8b6082d --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/changelog.yml @@ -0,0 +1,5 @@ +- version: 0.1.0 + changes: + - description: Initial release + type: enhancement + link: https://github.com/elastic/package-spec/pull/777 diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/docs/README.md new file mode 100644 index 0000000000000..3a6090d840af5 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/docs/README.md @@ -0,0 +1 @@ +# Reference package of content type diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/kibana-system.png b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/kibana-system.png new file mode 100644 index 0000000000000000000000000000000000000000..8741a5662417f189d8c87388583c4aa8ff3a6e5a GIT binary patch literal 205298 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≺8>u0!IUfH6o(Gc8;yv2{u~PokQe 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�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⁡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 literal 0 HcmV?d00001 diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/system.svg b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/system.svg new file mode 100644 index 0000000000000..0aba96275e24e --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/img/system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/manifest.yml new file mode 100644 index 0000000000000..4f03b0d37a444 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/manifest.yml @@ -0,0 +1,32 @@ +format_version: 3.2.0 +name: good_content +title: Good content package +description: > + This package is a dummy example for packages with the content type. + These packages contain resources that are useful with data ingested by other integrations. + They are not used to configure data sources. +version: 0.1.0 +type: content +source: + license: "Apache-2.0" +conditions: + kibana: + version: '^8.16.0' #TBD + elastic: + subscription: 'basic' +discovery: + fields: + - name: process.pid +screenshots: + - src: /img/kibana-system.png + title: kibana system + size: 1220x852 + type: image/png +icons: + - src: /img/system.svg + title: system + size: 1000x1000 + type: image/svg+xml +owner: + github: elastic/ecosystem + type: elastic diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/validation.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/validation.yml new file mode 100644 index 0000000000000..fa39a1d6f18bf --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/good_content/0.1.0/validation.yml @@ -0,0 +1,3 @@ +errors: + exclude_checks: + - PSR00002 # Allow to use non-GA features. diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index ea50aaaf53eb8..58a514b21a31d 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -642,6 +642,24 @@ export default function (providerContext: FtrProviderContext) { expect(body.item.inputs[0].enabled).to.eql(false); }); + it('should return 400 for content packages', async function () { + const response = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'content-pkg-policy', + description: '', + namespace: 'default', + policy_ids: [], + package: { + name: 'good_content', + version: '0.1.0', + }, + }) + .expect(400); + expect(response.body.message).to.eql('Cannot create policy for content only packages'); + }); + describe('input only packages', () => { it('should default dataset if not provided for input only pkg', async function () { await supertest From d87a38f6cccd1ed16ff5443cf19064ae57fd52d6 Mon Sep 17 00:00:00 2001 From: florent-leborgne Date: Tue, 15 Oct 2024 19:25:49 +0200 Subject: [PATCH 04/31] [Docs] Resize image for dashboard usage (#195914) This PR is a small fix to resize an image in the Dashboards docs to make it look better and not blurry --- docs/user/dashboard/view-dashboard-usage.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/dashboard/view-dashboard-usage.asciidoc b/docs/user/dashboard/view-dashboard-usage.asciidoc index 5ac7e72c3e246..8520c6348829a 100644 --- a/docs/user/dashboard/view-dashboard-usage.asciidoc +++ b/docs/user/dashboard/view-dashboard-usage.asciidoc @@ -6,4 +6,4 @@ image:images/view-details-dashboards-8.16.0.png[View details icon in the list of These details include a graph showing the total number of views during the last 90 days. -image:images/dashboard-usage-count.png[Graph showing the number of views during the last 90 days] \ No newline at end of file +image:images/dashboard-usage-count.png[Graph showing the number of views during the last 90 days, width="50%"] \ No newline at end of file From 7b9ff3d90cabe7e7f9e3ca4e6ecec31344afaff4 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Tue, 15 Oct 2024 19:28:55 +0200 Subject: [PATCH 05/31] [EDR Workflows] Enable automated response actions UI in all rules (#196051) --- .../common/detection_engine/utils.ts | 13 ----------- .../common/experimental_features.ts | 5 ---- .../components/step_rule_actions/index.tsx | 23 +++++-------------- .../pages/rule_creation/index.tsx | 2 -- .../pages/rule_editing/index.tsx | 2 -- .../rule_management/utils/validate.ts | 11 --------- 6 files changed, 6 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index a98ca169a41d7..e0cefdebecd93 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -93,16 +93,3 @@ export const isSuppressionRuleConfiguredWithMissingFields = (ruleType: Type) => export const isSuppressionRuleInGA = (ruleType: Type): boolean => { return isSuppressibleAlertRule(ruleType) && SUPPRESSIBLE_ALERT_RULES_GA.includes(ruleType); }; -export const shouldShowResponseActions = ( - ruleType: Type | undefined, - automatedResponseActionsForAllRulesEnabled: boolean -) => { - return ( - isQueryRule(ruleType) || - isEsqlRule(ruleType) || - isEqlRule(ruleType) || - isNewTermsRule(ruleType) || - (automatedResponseActionsForAllRulesEnabled && - (isThresholdRule(ruleType) || isThreatMatchRule(ruleType) || isMlRule(ruleType))) - ); -}; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index f18ddff6e4f17..5e438669916c6 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -52,11 +52,6 @@ export const allowedExperimentalValues = Object.freeze({ */ automatedProcessActionsEnabled: true, - /** - * Temporary feature flag to enable the Response Actions in Rules UI - intermediate release - */ - automatedResponseActionsForAllRulesEnabled: false, - /** * Enables the ability to send Response actions to SentinelOne and persist the results * in ES. Adds API changes to support `agentType` and supports `isolate` and `release` diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx index 06168ce97a2c7..ca79d429e43ad 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/step_rule_actions/index.tsx @@ -15,9 +15,6 @@ import type { ActionVariables, } from '@kbn/triggers-actions-ui-plugin/public'; import { UseArray } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { shouldShowResponseActions } from '../../../../../common/detection_engine/utils'; import type { RuleObjectId } from '../../../../../common/api/detection_engine/model/rule_schema'; import { ResponseActionsForm } from '../../../rule_response_actions/response_actions_form'; import type { @@ -40,7 +37,6 @@ interface StepRuleActionsProps extends RuleStepProps { ruleId?: RuleObjectId; // Rule SO's id (not ruleId) actionMessageParams: ActionVariables; summaryActionMessageParams: ActionVariables; - ruleType?: Type; form: FormHook; } @@ -79,15 +75,11 @@ const StepRuleActionsComponent: FC = ({ isUpdateView = false, actionMessageParams, summaryActionMessageParams, - ruleType, form, }) => { const { services: { application }, } = useKibana(); - const automatedResponseActionsForAllRulesEnabled = useIsExperimentalFeatureEnabled( - 'automatedResponseActionsForAllRulesEnabled' - ); const displayActionsOptions = useMemo( () => ( <> @@ -105,15 +97,12 @@ const StepRuleActionsComponent: FC = ({ [actionMessageParams, summaryActionMessageParams] ); const displayResponseActionsOptions = useMemo(() => { - if (shouldShowResponseActions(ruleType, automatedResponseActionsForAllRulesEnabled)) { - return ( - - {ResponseActionsForm} - - ); - } - return null; - }, [automatedResponseActionsForAllRulesEnabled, ruleType]); + return ( + + {ResponseActionsForm} + + ); + }, []); // only display the actions dropdown if the user has "read" privileges for actions const displayActionsDropDown = useMemo(() => { return application.capabilities.actions.show ? ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx index 500fedb4d0005..28d137ac522ae 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx @@ -789,7 +789,6 @@ const CreateRulePageComponent: React.FC = () => { isLoading={isCreateRuleLoading || loading || isStartingJobs} actionMessageParams={actionMessageParams} summaryActionMessageParams={actionMessageParams} - ruleType={ruleType} form={actionsStepForm} /> @@ -841,7 +840,6 @@ const CreateRulePageComponent: React.FC = () => { isCreateRuleLoading, isStartingJobs, loading, - ruleType, submitRuleDisabled, submitRuleEnabled, ] diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index f8db919ff9416..9151e6965bd11 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -348,7 +348,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { isUpdateView actionMessageParams={actionMessageParams} summaryActionMessageParams={actionMessageParams} - ruleType={rule?.type} form={actionsStepForm} key="actionsStep" /> @@ -362,7 +361,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { [ rule?.immutable, rule?.id, - rule?.type, activeStep, loading, isSavedQueryLoading, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts index 5ff9d2d97f2b0..a61c28b5ced3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts @@ -8,7 +8,6 @@ import type { PartialRule } from '@kbn/alerting-plugin/server'; import { isEqual, xorWith } from 'lodash'; import { stringifyZodError } from '@kbn/zod-helpers'; -import { shouldShowResponseActions } from '../../../../../common/detection_engine/utils'; import { type ResponseAction, type RuleCreateProps, @@ -59,16 +58,6 @@ export const validateResponseActionsPermissions = async ( ruleUpdate: RuleCreateProps | RuleUpdateProps, existingRule?: RuleAlertType | null ): Promise => { - const { experimentalFeatures } = await securitySolution.getConfig(); - if ( - !shouldShowResponseActions( - ruleUpdate.type, - experimentalFeatures.automatedResponseActionsForAllRulesEnabled - ) - ) { - return; - } - if ( !rulePayloadContainsResponseActions(ruleUpdate) || (existingRule && !ruleObjectContainsResponseActions(existingRule)) From 302ac0d336feb861522c9ca3f3c271e172b86ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Wed, 16 Oct 2024 00:58:43 +0700 Subject: [PATCH 06/31] Add support for GeoIP processor databases in Ingest Pipelines (#190830) Fixes https://github.com/elastic/kibana/issues/190818 ## Summary Elasticsearch has added support for GeoIP, enabling the use of paid GeoIP databases from MaxMind/IPInfo for more accurate and granular geolocation data. As such we should add support to ingest pipelines UI for making this available to the user. * If the user doesn't have enough privileges, the "Manage Pipelines" link and UI won't show. * Users can add two types of databases through the UI: MaxMind and IPinfo. Database names are predefined by ES, and the user cannot enter their own. * Certain types of databases (local and web) can be configured through ES, and these will appear in the UI, but they cannot be deleted as they are read-only. * When configuring a `IP location` processor, the database field will display a list of available and configured databases that the user can select. It also allows for free-text input if the user wants to configure a database that does not yet exist. * The new IP location processor is essentially a clone of the GeoIP processor, which we are moving away from due to copyright issues. However, it was decided that GeoIP will remain as is for backward compatibility, and all new work will only be added to IP location going forward. * I left a few mocks in the `server/routes/api/geoip_database/list.ts ` to try `local/web` types ## Release note The Ingest Pipelines app now supports adding and managing databases for the GeoIP processor. Additionally, the pipeline creation flow now includes support for the IP Location processor.
Screenshots ![Screenshot 2024-10-07 at 09 36 31](https://github.com/user-attachments/assets/60d438cc-6658-4475-bd27-036c7d13d496) ![Screenshot 2024-10-07 at 09 38 58](https://github.com/user-attachments/assets/7c08e94f-b35c-4e78-a204-1fb456d88181) ![Screenshot 2024-10-07 at 09 47 08](https://github.com/user-attachments/assets/2baca0bd-811d-4dd5-9eb6-9b3f41579249) ![Screenshot 2024-10-07 at 09 47 20](https://github.com/user-attachments/assets/74d8664c-8c73-41f3-8cd5-e0670f3ada77) ![Screenshot 2024-10-07 at 09 48 19](https://github.com/user-attachments/assets/9fb4c186-6224-404c-a8d6-5c44c14da951) ![Screenshot 2024-10-07 at 09 48 25](https://github.com/user-attachments/assets/07e4909d-2613-45aa-918b-11a189e14f6f)
--------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine Co-authored-by: Ignacio Rivas Co-authored-by: Elena Stoeva Co-authored-by: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Co-authored-by: Matthew Kime --- config/serverless.yml | 3 + .../test_suites/core_plugins/rendering.ts | 1 + .../helpers/http_requests.ts | 15 + .../client_integration/helpers/index.ts | 4 +- .../helpers/manage_processors.helpers.ts | 144 +++++++++ .../helpers/setup_environment.tsx | 3 + .../manage_processors.test.tsx | 187 ++++++++++++ .../plugins/ingest_pipelines/common/types.ts | 17 +- .../public/application/app.tsx | 29 +- .../processor_form/processors/index.ts | 1 + .../processor_form/processors/ip_location.tsx | 131 ++++++++ .../shared/map_processor_type_to_form.tsx | 19 ++ .../public/application/constants/index.ts | 1 + .../public/application/index.tsx | 5 +- .../application/mount_management_section.ts | 6 +- .../public/application/sections/index.ts | 2 + .../manage_processors/add_database_modal.tsx | 280 ++++++++++++++++++ .../sections/manage_processors/constants.ts | 176 +++++++++++ .../delete_database_modal.tsx | 135 +++++++++ .../sections/manage_processors/empty_list.tsx | 36 +++ .../sections/manage_processors/geoip_list.tsx | 202 +++++++++++++ .../manage_processors/get_error_message.tsx | 27 ++ .../sections/manage_processors/index.ts | 9 + .../manage_processors/manage_processors.tsx | 44 +++ .../use_check_manage_processors_privileges.ts | 15 + .../sections/pipelines_list/main.tsx | 121 +++++--- .../public/application/services/api.ts | 37 ++- .../application/services/breadcrumbs.ts | 13 +- .../public/application/services/navigation.ts | 8 + .../plugins/ingest_pipelines/public/index.ts | 5 +- .../plugins/ingest_pipelines/public/plugin.ts | 12 +- .../plugins/ingest_pipelines/public/types.ts | 4 + .../plugins/ingest_pipelines/server/config.ts | 29 ++ .../plugins/ingest_pipelines/server/index.ts | 8 +- .../plugins/ingest_pipelines/server/plugin.ts | 8 +- .../server/routes/api/database/create.ts | 74 +++++ .../server/routes/api/database/delete.ts | 40 +++ .../server/routes/api/database/index.ts | 10 + .../server/routes/api/database/list.ts | 37 +++ .../api/database/normalize_database_name.ts | 10 + .../routes/api/database/serialization.ts | 94 ++++++ .../server/routes/api/index.ts | 6 + .../server/routes/api/privileges.ts | 20 +- .../ingest_pipelines/server/routes/index.ts | 8 + .../plugins/ingest_pipelines/server/types.ts | 1 + x-pack/plugins/ingest_pipelines/tsconfig.json | 4 +- .../management/ingest_pipelines/databases.ts | 67 +++++ .../apis/management/ingest_pipelines/index.ts | 14 + .../ingest_pipelines/geoip_databases.ts | 6 + .../services/ingest_pipelines/lib/api.ts | 15 + .../functional/apps/ingest_pipelines/index.ts | 1 + .../ingest_pipelines/manage_processors.ts | 95 ++++++ x-pack/test/functional/config.base.js | 14 + .../page_objects/ingest_pipelines_page.ts | 53 ++++ 54 files changed, 2218 insertions(+), 88 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts create mode 100644 x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/config.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts create mode 100644 x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts create mode 100644 x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts create mode 100644 x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts create mode 100644 x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts create mode 100644 x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts diff --git a/config/serverless.yml b/config/serverless.yml index 8f7857988d77e..4249d8ff786ec 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -113,6 +113,9 @@ xpack.index_management.enableTogglingDataRetention: false # Disable project level rentention checks in DSL form from Index Management UI xpack.index_management.enableProjectLevelRetentionChecks: false +# Disable Manage Processors UI in Ingest Pipelines +xpack.ingest_pipelines.enableManageProcessors: false + # Keep deeplinks visible so that they are shown in the sidenav dev_tools.deeplinks.navLinkStatus: visible management.deeplinks.navLinkStatus: visible diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 02355c97823cf..6a863a78cff15 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -314,6 +314,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.ml.nlp.modelDeployment.vCPURange.medium.static (number?)', 'xpack.osquery.actionEnabled (boolean?)', 'xpack.remote_clusters.ui.enabled (boolean?)', + 'xpack.ingest_pipelines.enableManageProcessors (boolean?|never)', /** * NOTE: The Reporting plugin is currently disabled in functional tests (see test/functional/config.base.js). * It will be re-enabled once #102552 is completed. diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts index d7c833ef85403..e9793791a394e 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts @@ -73,12 +73,27 @@ const registerHttpRequestMockHelpers = ( const setParseCsvResponse = (response?: object, error?: ResponseError) => mockResponse('POST', `${API_BASE_PATH}/parse_csv`, response, error); + const setLoadDatabasesResponse = (response?: object[], error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/databases`, response, error); + + const setDeleteDatabasesResponse = ( + databaseName: string, + response?: object, + error?: ResponseError + ) => mockResponse('DELETE', `${API_BASE_PATH}/databases/${databaseName}`, response, error); + + const setCreateDatabasesResponse = (response?: object, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/databases`, response, error); + return { setLoadPipelinesResponse, setLoadPipelineResponse, setDeletePipelineResponse, setCreatePipelineResponse, setParseCsvResponse, + setLoadDatabasesResponse, + setDeleteDatabasesResponse, + setCreateDatabasesResponse, }; }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts index 5f4dc01fa924a..31cf685e35533 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts @@ -10,8 +10,9 @@ import { setup as pipelinesCreateSetup } from './pipelines_create.helpers'; import { setup as pipelinesCloneSetup } from './pipelines_clone.helpers'; import { setup as pipelinesEditSetup } from './pipelines_edit.helpers'; import { setup as pipelinesCreateFromCsvSetup } from './pipelines_create_from_csv.helpers'; +import { setup as manageProcessorsSetup } from './manage_processors.helpers'; -export { nextTick, getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; +export { getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; export { setupEnvironment } from './setup_environment'; @@ -21,4 +22,5 @@ export const pageHelpers = { pipelinesClone: { setup: pipelinesCloneSetup }, pipelinesEdit: { setup: pipelinesEditSetup }, pipelinesCreateFromCsv: { setup: pipelinesCreateFromCsvSetup }, + manageProcessors: { setup: manageProcessorsSetup }, }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts new file mode 100644 index 0000000000000..d0127943d7fa3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { HttpSetup } from '@kbn/core/public'; + +import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { ManageProcessors } from '../../../public/application/sections'; +import { WithAppDependencies } from './setup_environment'; +import { getManageProcessorsPath, ROUTES } from '../../../public/application/services/navigation'; + +const testBedConfig: AsyncTestBedConfig = { + memoryRouter: { + initialEntries: [getManageProcessorsPath()], + componentRoutePath: ROUTES.manageProcessors, + }, + doMountAsync: true, +}; + +export type ManageProcessorsTestBed = TestBed & { + actions: ReturnType; +}; + +const createActions = (testBed: TestBed) => { + const { component, find, form } = testBed; + + const clickDeleteDatabaseButton = async (index: number) => { + const allDeleteButtons = find('deleteGeoipDatabaseButton'); + const deleteButton = allDeleteButtons.at(index); + await act(async () => { + deleteButton.simulate('click'); + }); + + component.update(); + }; + + const confirmDeletingDatabase = async () => { + await act(async () => { + form.setInputValue('geoipDatabaseConfirmation', 'delete'); + }); + + component.update(); + + const confirmButton: HTMLButtonElement | null = document.body.querySelector( + '[data-test-subj="deleteGeoipDatabaseSubmit"]' + ); + + expect(confirmButton).not.toBe(null); + expect(confirmButton!.disabled).toBe(false); + expect(confirmButton!.textContent).toContain('Delete'); + + await act(async () => { + confirmButton!.click(); + }); + + component.update(); + }; + + const clickAddDatabaseButton = async () => { + const button = find('addGeoipDatabaseButton'); + expect(button).not.toBe(undefined); + await act(async () => { + button.simulate('click'); + }); + + component.update(); + }; + + const fillOutDatabaseValues = async ( + databaseType: string, + databaseName: string, + maxmind?: string + ) => { + await act(async () => { + form.setSelectValue('databaseTypeSelect', databaseType); + }); + component.update(); + + if (maxmind) { + await act(async () => { + form.setInputValue('maxmindField', maxmind); + }); + } + await act(async () => { + form.setSelectValue('databaseNameSelect', databaseName); + }); + + component.update(); + }; + + const confirmAddingDatabase = async () => { + const confirmButton: HTMLButtonElement | null = document.body.querySelector( + '[data-test-subj="addGeoipDatabaseSubmit"]' + ); + + expect(confirmButton).not.toBe(null); + expect(confirmButton!.disabled).toBe(false); + + await act(async () => { + confirmButton!.click(); + }); + + component.update(); + }; + + return { + clickDeleteDatabaseButton, + confirmDeletingDatabase, + clickAddDatabaseButton, + fillOutDatabaseValues, + confirmAddingDatabase, + }; +}; + +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ManageProcessors, httpSetup), + testBedConfig + ); + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type ManageProcessorsTestSubjects = + | 'manageProcessorsTitle' + | 'addGeoipDatabaseForm' + | 'addGeoipDatabaseButton' + | 'geoipDatabaseList' + | 'databaseTypeSelect' + | 'maxmindField' + | 'databaseNameSelect' + | 'addGeoipDatabaseSubmit' + | 'deleteGeoipDatabaseButton' + | 'geoipDatabaseConfirmation' + | 'geoipEmptyListPrompt' + | 'geoipListLoadingError'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index 58701ffb1dd64..6725a7381decf 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -70,6 +70,9 @@ const appServices = { }, overlays: overlayServiceMock.createStartContract(), http: httpServiceMock.createStartContract({ basePath: '/mock' }), + config: { + enableManageProcessors: true, + }, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx new file mode 100644 index 0000000000000..81375d1e3ae83 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; + +import { ManageProcessorsTestBed } from './helpers/manage_processors.helpers'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import type { GeoipDatabase } from '../../common/types'; +import { API_BASE_PATH } from '../../common/constants'; + +const { setup } = pageHelpers.manageProcessors; + +describe('', () => { + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: ManageProcessorsTestBed; + + describe('With databases', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(httpSetup); + }); + + testBed.component.update(); + }); + + const database1: GeoipDatabase = { + name: 'GeoIP2-Anonymous-IP', + id: 'geoip2-anonymous-ip', + type: 'maxmind', + }; + + const database2: GeoipDatabase = { + name: 'GeoIP2-City', + id: 'geoip2-city', + type: 'maxmind', + }; + + const database3: GeoipDatabase = { + name: 'GeoIP2-Country', + id: 'geoip2-country', + type: 'maxmind', + }; + + const database4: GeoipDatabase = { + name: 'Free-IP-to-ASN', + id: 'free-ip-to-asn', + type: 'ipinfo', + }; + + const databases = [database1, database2, database3, database4]; + + httpRequestsMockHelpers.setLoadDatabasesResponse(databases); + + test('renders the list of databases', async () => { + const { exists, find, table } = testBed; + + // Page title + expect(exists('manageProcessorsTitle')).toBe(true); + expect(find('manageProcessorsTitle').text()).toEqual('Manage Processors'); + + // Add database button + expect(exists('addGeoipDatabaseButton')).toBe(true); + + // Table has columns for database name and type + const { tableCellsValues } = table.getMetaData('geoipDatabaseList'); + tableCellsValues.forEach((row, i) => { + const database = databases[i]; + + expect(row).toEqual([ + database.name, + database.type === 'maxmind' ? 'MaxMind' : 'IPInfo', + '', + ]); + }); + }); + + test('deletes a database', async () => { + const { actions } = testBed; + const databaseIndexToDelete = 0; + const databaseName = databases[databaseIndexToDelete].name; + httpRequestsMockHelpers.setDeleteDatabasesResponse(databaseName, {}); + + await actions.clickDeleteDatabaseButton(databaseIndexToDelete); + + await actions.confirmDeletingDatabase(); + + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/databases/${databaseName.toLowerCase()}`, + expect.anything() + ); + }); + }); + + describe('Creates a database', () => { + it('creates a MaxMind database when none with the same name exists', async () => { + const { actions, exists } = testBed; + const databaseName = 'GeoIP2-ISP'; + const maxmind = '123456'; + httpRequestsMockHelpers.setCreateDatabasesResponse({ + name: databaseName, + id: databaseName.toLowerCase(), + }); + + await actions.clickAddDatabaseButton(); + + expect(exists('addGeoipDatabaseForm')).toBe(true); + + await actions.fillOutDatabaseValues('maxmind', databaseName, maxmind); + + await actions.confirmAddingDatabase(); + + expect(httpSetup.post).toHaveBeenLastCalledWith(`${API_BASE_PATH}/databases`, { + asSystemRequest: undefined, + body: '{"databaseType":"maxmind","databaseName":"GeoIP2-ISP","maxmind":"123456"}', + query: undefined, + version: undefined, + }); + }); + + it('creates an IPInfo database when none with the same name exists', async () => { + const { actions, exists } = testBed; + const databaseName = 'ASN'; + httpRequestsMockHelpers.setCreateDatabasesResponse({ + name: databaseName, + id: databaseName.toLowerCase(), + }); + + await actions.clickAddDatabaseButton(); + + expect(exists('addGeoipDatabaseForm')).toBe(true); + + await actions.fillOutDatabaseValues('ipinfo', databaseName); + + await actions.confirmAddingDatabase(); + + expect(httpSetup.post).toHaveBeenLastCalledWith(`${API_BASE_PATH}/databases`, { + asSystemRequest: undefined, + body: '{"databaseType":"ipinfo","databaseName":"ASN","maxmind":""}', + query: undefined, + version: undefined, + }); + }); + }); + + describe('No databases', () => { + test('displays an empty prompt', async () => { + httpRequestsMockHelpers.setLoadDatabasesResponse([]); + + await act(async () => { + testBed = await setup(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('geoipEmptyListPrompt')).toBe(true); + }); + }); + + describe('Error handling', () => { + beforeEach(async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadDatabasesResponse(undefined, error); + + await act(async () => { + testBed = await setup(httpSetup); + }); + + testBed.component.update(); + }); + + test('displays an error callout', async () => { + const { exists } = testBed; + + expect(exists('geoipListLoadingError')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/common/types.ts b/x-pack/plugins/ingest_pipelines/common/types.ts index c526facdedab8..4c68b443fb8fb 100644 --- a/x-pack/plugins/ingest_pipelines/common/types.ts +++ b/x-pack/plugins/ingest_pipelines/common/types.ts @@ -28,16 +28,15 @@ export interface Pipeline { deprecated?: boolean; } -export interface PipelinesByName { - [key: string]: { - description: string; - version?: number; - processors: Processor[]; - on_failure?: Processor[]; - }; -} - export enum FieldCopyAction { Copy = 'copy', Rename = 'rename', } + +export type DatabaseType = 'maxmind' | 'ipinfo' | 'web' | 'local' | 'unknown'; + +export interface GeoipDatabase { + name: string; + id: string; + type: DatabaseType; +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/app.tsx b/x-pack/plugins/ingest_pipelines/public/application/app.tsx index 6b47ed277673e..045db4511e181 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/app.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/app.tsx @@ -27,20 +27,27 @@ import { PipelinesEdit, PipelinesClone, PipelinesCreateFromCsv, + ManageProcessors, } from './sections'; import { ROUTES } from './services/navigation'; -export const AppWithoutRouter = () => ( - - - - - - - {/* Catch all */} - - -); +export const AppWithoutRouter = () => { + const { services } = useKibana(); + return ( + + + + + + + {services.config.enableManageProcessors && ( + + )} + {/* Catch all */} + + + ); +}; export const App: FunctionComponent = () => { const { apiError } = useAuthorizationContext(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts index 2e4dc65f32314..b55337f088887 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts @@ -25,6 +25,7 @@ export { Fingerprint } from './fingerprint'; export { Foreach } from './foreach'; export { GeoGrid } from './geogrid'; export { GeoIP } from './geoip'; +export { IpLocation } from './ip_location'; export { Grok } from './grok'; export { Gsub } from './gsub'; export { HtmlStrip } from './html_strip'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx new file mode 100644 index 0000000000000..d1b8fbd7ea513 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode } from '@elastic/eui'; +import { groupBy, map } from 'lodash'; + +import { + FIELD_TYPES, + UseField, + ToggleField, + ComboBoxField, +} from '../../../../../../shared_imports'; + +import { useKibana } from '../../../../../../shared_imports'; +import { FieldNameField } from './common_fields/field_name_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldsConfig, from, to } from './shared'; +import { TargetField } from './common_fields/target_field'; +import { PropertiesField } from './common_fields/properties_field'; +import type { GeoipDatabase } from '../../../../../../../common/types'; +import { getTypeLabel } from '../../../../../sections/manage_processors/constants'; + +const fieldsConfig: FieldsConfig = { + /* Optional field config */ + database_file: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: (v: string[]) => (v.length ? v[0] : undefined), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.ipLocationForm.databaseFileLabel', { + defaultMessage: 'Database file (optional)', + }), + helpText: ( + {'GeoLite2-City.mmdb'}, + ingestGeoIP: {'ingest-geoip'}, + }} + /> + ), + }, + + first_only: { + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(true), + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.ipLocationForm.firstOnlyFieldLabel', + { + defaultMessage: 'First only', + } + ), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.ipLocationForm.firstOnlyFieldHelpText', + { + defaultMessage: 'Use the first matching geo data, even if the field contains an array.', + } + ), + }, +}; + +export const IpLocation: FunctionComponent = () => { + const { services } = useKibana(); + const { data, isLoading } = services.api.useLoadDatabases(); + + const dataAsOptions = (data || []).map((item) => ({ + id: item.id, + type: item.type, + label: item.name, + })); + const optionsByGroup = groupBy(dataAsOptions, 'type'); + const groupedOptions = map(optionsByGroup, (items, groupName) => ({ + label: getTypeLabel(groupName as GeoipDatabase['type']), + options: map(items, (item) => item), + })); + + return ( + <> + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 5d672deb739d3..6618e1bd9b352 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -32,6 +32,7 @@ import { Foreach, GeoGrid, GeoIP, + IpLocation, Grok, Gsub, HtmlStrip, @@ -477,6 +478,24 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, }), }, + ip_location: { + category: processorCategories.DATA_ENRICHMENT, + FieldsComponent: IpLocation, + docLinkPath: '/geoip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.ipLocation', { + defaultMessage: 'IP Location', + }), + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.ipLocation', { + defaultMessage: 'Adds geo data based on an IP address.', + }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.ipLocation', { + defaultMessage: 'Adds geo data to documents based on the value of "{field}"', + values: { + field, + }, + }), + }, grok: { category: processorCategories.DATA_TRANSFORMATION, FieldsComponent: Grok, diff --git a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts index 3c415bf9e0682..03aa734800ff6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts @@ -13,3 +13,4 @@ export const UIM_PIPELINE_UPDATE = 'pipeline_update'; export const UIM_PIPELINE_DELETE = 'pipeline_delete'; export const UIM_PIPELINE_DELETE_MANY = 'pipeline_delete_many'; export const UIM_PIPELINE_SIMULATE = 'pipeline_simulate'; +export const UIM_MANAGE_PROCESSORS = 'manage_processes'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index 6ec215db8b043..9bc3ba7fe27ad 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -18,7 +18,7 @@ import type { FileUploadPluginStart } from '@kbn/file-upload-plugin/public'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; import { KibanaContextProvider, KibanaRenderContextProvider } from '../shared_imports'; -import { ILicense } from '../types'; +import type { Config, ILicense } from '../types'; import { API_BASE_PATH } from '../../common/constants'; @@ -50,6 +50,7 @@ export interface AppServices { consolePlugin?: ConsolePluginStart; overlays: OverlayStart; http: HttpStart; + config: Config; } type StartServices = Pick; @@ -66,7 +67,7 @@ export const renderApp = ( render( diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index 4b6ca4f35cd3f..c4382e73720d7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -8,7 +8,7 @@ import { CoreSetup } from '@kbn/core/public'; import { ManagementAppMountParams } from '@kbn/management-plugin/public'; -import { StartDependencies, ILicense } from '../types'; +import type { StartDependencies, ILicense, Config } from '../types'; import { documentationService, uiMetricService, @@ -20,13 +20,14 @@ import { renderApp } from '.'; export interface AppParams extends ManagementAppMountParams { license: ILicense | null; + config: Config; } export async function mountManagementSection( { http, getStartServices, notifications }: CoreSetup, params: AppParams ) { - const { element, setBreadcrumbs, history, license } = params; + const { element, setBreadcrumbs, history, license, config } = params; const [coreStart, depsStart] = await getStartServices(); const { docLinks, application, executionContext, overlays } = coreStart; @@ -51,6 +52,7 @@ export async function mountManagementSection( consolePlugin: depsStart.console, overlays, http, + config, }; return renderApp(element, services, { ...coreStart, http }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts index bd3ab41936b29..f299c9ec0db74 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts @@ -14,3 +14,5 @@ export { PipelinesEdit } from './pipelines_edit'; export { PipelinesClone } from './pipelines_clone'; export { PipelinesCreateFromCsv } from './pipelines_create_from_csv'; + +export { ManageProcessors } from './manage_processors'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx new file mode 100644 index 0000000000000..6289fe3953f3e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelect, + EuiSpacer, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import type { GeoipDatabase } from '../../../../common/types'; +import { useKibana } from '../../../shared_imports'; +import { + ADD_DATABASE_MODAL_TITLE_ID, + ADD_DATABASE_MODAL_FORM_ID, + DATABASE_TYPE_OPTIONS, + GEOIP_NAME_OPTIONS, + IPINFO_NAME_OPTIONS, + getAddDatabaseSuccessMessage, + addDatabaseErrorTitle, +} from './constants'; + +export const AddDatabaseModal = ({ + closeModal, + reloadDatabases, + databases, +}: { + closeModal: () => void; + reloadDatabases: () => void; + databases: GeoipDatabase[]; +}) => { + const [databaseType, setDatabaseType] = useState(undefined); + const [maxmind, setMaxmind] = useState(''); + const [databaseName, setDatabaseName] = useState(''); + const [nameExistsError, setNameExistsError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const existingDatabaseNames = useMemo( + () => databases.map((database) => database.name), + [databases] + ); + const { services } = useKibana(); + const onDatabaseNameChange = (value: string) => { + setDatabaseName(value); + setNameExistsError(existingDatabaseNames.includes(value)); + }; + const isFormValid = (): boolean => { + if (!databaseType || nameExistsError) { + return false; + } + if (databaseType === 'maxmind') { + return Boolean(maxmind) && Boolean(databaseName); + } + return Boolean(databaseName); + }; + const onDatabaseTypeChange = (value: string) => { + setDatabaseType(value); + }; + const onAddDatabase = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isFormValid()) { + return; + } + setIsLoading(true); + try { + const { error } = await services.api.createDatabase({ + databaseType: databaseType!, + databaseName, + maxmind, + }); + setIsLoading(false); + if (error) { + services.notifications.toasts.addError(error, { + title: addDatabaseErrorTitle, + }); + } else { + services.notifications.toasts.addSuccess(getAddDatabaseSuccessMessage(databaseName)); + await reloadDatabases(); + closeModal(); + } + } catch (e) { + setIsLoading(false); + services.notifications.toasts.addError(e, { + title: addDatabaseErrorTitle, + }); + } + }; + + return ( + + + + + + + + + onAddDatabase(event)} + data-test-subj="addGeoipDatabaseForm" + > + + } + helpText={ + + } + > + onDatabaseTypeChange(e.target.value)} + data-test-subj="databaseTypeSelect" + /> + + {databaseType === 'maxmind' && ( + <> + + + } + iconType="iInCircle" + > +

+ +

+
+ + + )} + {databaseType === 'ipinfo' && ( + <> + + + } + iconType="iInCircle" + > +

+ +

+
+ + + )} + + {databaseType === 'maxmind' && ( + + } + > + setMaxmind(e.target.value)} + data-test-subj="maxmindField" + /> + + )} + {databaseType && ( + + } + > + onDatabaseNameChange(e.target.value)} + data-test-subj="databaseNameSelect" + /> + + )} +
+ + {nameExistsError && ( + <> + + + } + iconType="warning" + > +

+ +

+
+ + )} +
+ + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts new file mode 100644 index 0000000000000..799c3a8c29b40 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { GeoipDatabase } from '../../../../common/types'; + +export const ADD_DATABASE_MODAL_TITLE_ID = 'manageProcessorsAddGeoipDatabase'; +export const ADD_DATABASE_MODAL_FORM_ID = 'manageProcessorsAddGeoipDatabaseForm'; +export const DATABASE_TYPE_OPTIONS = [ + { + value: 'maxmind', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.maxmindDatabaseType', { + defaultMessage: 'MaxMind', + }), + }, + { + value: 'ipinfo', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.ipinfoDatabaseType', { + defaultMessage: 'IPInfo', + }), + }, +]; +export const GEOIP_NAME_OPTIONS = [ + { + value: 'GeoIP2-Anonymous-IP', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.anonymousIPDatabaseName', { + defaultMessage: 'GeoIP2 Anonymous IP', + }), + }, + { + value: 'GeoIP2-City', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.cityDatabaseName', { + defaultMessage: 'GeoIP2 City', + }), + }, + { + value: 'GeoIP2-Connection-Type', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.connectionTypeDatabaseName', + { + defaultMessage: 'GeoIP2 Connection Type', + } + ), + }, + { + value: 'GeoIP2-Country', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.countryDatabaseName', { + defaultMessage: 'GeoIP2 Country', + }), + }, + { + value: 'GeoIP2-Domain', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.domainDatabaseName', { + defaultMessage: 'GeoIP2 Domain', + }), + }, + { + value: 'GeoIP2-Enterprise', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.enterpriseDatabaseName', { + defaultMessage: 'GeoIP2 Enterprise', + }), + }, + { + value: 'GeoIP2-ISP', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.ispDatabaseName', { + defaultMessage: 'GeoIP2 ISP', + }), + }, +]; +export const IPINFO_NAME_OPTIONS = [ + { + value: 'asn', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.freeAsnDatabaseName', { + defaultMessage: 'Free IP to ASN', + }), + }, + { + value: 'country', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.freeCountryDatabaseName', { + defaultMessage: 'Free IP to Country', + }), + }, + { + value: 'standard_asn', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.asnDatabaseName', { + defaultMessage: 'ASN', + }), + }, + { + value: 'standard_location', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.ipinfo.ipGeolocationDatabaseName', + { + defaultMessage: 'IP Geolocation', + } + ), + }, + { + value: 'standard_privacy', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.ipinfo.privacyDetectionDatabaseName', + { + defaultMessage: 'Privacy Detection', + } + ), + }, +]; + +export const getAddDatabaseSuccessMessage = (databaseName: string): string => { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.addDatabaseSuccessMessage', { + defaultMessage: 'Added database {databaseName}', + values: { databaseName }, + }); +}; + +export const addDatabaseErrorTitle = i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.addDatabaseErrorTitle', + { + defaultMessage: 'Error adding database', + } +); + +export const DELETE_DATABASE_MODAL_TITLE_ID = 'manageProcessorsDeleteGeoipDatabase'; +export const DELETE_DATABASE_MODAL_FORM_ID = 'manageProcessorsDeleteGeoipDatabaseForm'; + +export const getDeleteDatabaseSuccessMessage = (databaseName: string): string => { + return i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseSuccessMessage', + { + defaultMessage: 'Deleted database {databaseName}', + values: { databaseName }, + } + ); +}; + +export const deleteDatabaseErrorTitle = i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseErrorTitle', + { + defaultMessage: 'Error deleting database', + } +); + +export const getTypeLabel = (type: GeoipDatabase['type']): string => { + switch (type) { + case 'maxmind': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeMaxmindLabel', { + defaultMessage: 'MaxMind', + }); + } + case 'ipinfo': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeIpinfoLabel', { + defaultMessage: 'IPInfo', + }); + } + case 'web': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.webLabel', { + defaultMessage: 'Web', + }); + } + case 'local': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.localLabel', { + defaultMessage: 'Local', + }); + } + case 'unknown': + default: { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeUnknownLabel', { + defaultMessage: 'Unknown', + }); + } + } +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx new file mode 100644 index 0000000000000..711fab34984a5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useState } from 'react'; +import type { GeoipDatabase } from '../../../../common/types'; +import { useKibana } from '../../../shared_imports'; +import { + DELETE_DATABASE_MODAL_FORM_ID, + DELETE_DATABASE_MODAL_TITLE_ID, + deleteDatabaseErrorTitle, + getDeleteDatabaseSuccessMessage, +} from './constants'; + +export const DeleteDatabaseModal = ({ + closeModal, + database, + reloadDatabases, +}: { + closeModal: () => void; + database: GeoipDatabase; + reloadDatabases: () => void; +}) => { + const [confirmation, setConfirmation] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const isValid = confirmation === 'delete'; + const { services } = useKibana(); + const onDeleteDatabase = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isValid) { + return; + } + setIsLoading(true); + try { + const { error } = await services.api.deleteDatabase(database.id); + setIsLoading(false); + if (error) { + services.notifications.toasts.addError(error, { + title: deleteDatabaseErrorTitle, + }); + } else { + services.notifications.toasts.addSuccess(getDeleteDatabaseSuccessMessage(database.name)); + await reloadDatabases(); + closeModal(); + } + } catch (e) { + setIsLoading(false); + services.notifications.toasts.addError(e, { + title: deleteDatabaseErrorTitle, + }); + } + }; + return ( + + + + + + + + + onDeleteDatabase(event)} + > + + } + > + setConfirmation(e.target.value)} + data-test-subj="geoipDatabaseConfirmation" + /> + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx new file mode 100644 index 0000000000000..d5e908b155feb --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPageTemplate } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +export const EmptyList = ({ addDatabaseButton }: { addDatabaseButton: JSX.Element }) => { + return ( + + + + } + body={ +

+ +

+ } + actions={addDatabaseButton} + /> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx new file mode 100644 index 0000000000000..e09ac4e6e2c4d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiPageTemplate, + EuiSpacer, + EuiTitle, + EuiButtonIcon, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; + +import { IPINFO_NAME_OPTIONS } from './constants'; +import type { GeoipDatabase } from '../../../../common/types'; +import { SectionLoading, useKibana } from '../../../shared_imports'; +import { getTypeLabel } from './constants'; +import { EmptyList } from './empty_list'; +import { AddDatabaseModal } from './add_database_modal'; +import { DeleteDatabaseModal } from './delete_database_modal'; +import { getErrorMessage } from './get_error_message'; + +export const GeoipList: React.FunctionComponent = () => { + const { services } = useKibana(); + const { data, isLoading, error, resendRequest } = services.api.useLoadDatabases(); + const [showModal, setShowModal] = useState<'add' | 'delete' | null>(null); + const [databaseToDelete, setDatabaseToDelete] = useState(null); + const onDatabaseDelete = (item: GeoipDatabase) => { + setDatabaseToDelete(item); + setShowModal('delete'); + }; + let content: JSX.Element; + const addDatabaseButton = ( + { + setShowModal('add'); + }} + data-test-subj="addGeoipDatabaseButton" + > + + + ); + const tableProps: EuiInMemoryTableProps = { + 'data-test-subj': 'geoipDatabaseList', + rowProps: () => ({ + 'data-test-subj': 'geoipDatabaseListRow', + }), + columns: [ + { + field: 'name', + name: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.nameColumnTitle', { + defaultMessage: 'Database name', + }), + sortable: true, + render: (name: string, row) => { + if (row.type === 'ipinfo') { + // find the name in the options to get the translated value + const option = IPINFO_NAME_OPTIONS.find((opt) => opt.value === name); + return option?.text ?? name; + } + + return name; + }, + }, + { + field: 'type', + name: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeColumnTitle', { + defaultMessage: 'Type', + }), + sortable: true, + render: (type: GeoipDatabase['type']) => { + return getTypeLabel(type); + }, + }, + { + name: 'Actions', + align: 'right', + render: (item: GeoipDatabase) => { + // Local and web databases are read only and cannot be deleted through UI + if (['web', 'local'].includes(item.type)) { + return; + } + + return ( + onDatabaseDelete(item)} + data-test-subj="deleteGeoipDatabaseButton" + /> + ); + }, + }, + ], + items: data ?? [], + }; + if (error) { + content = ( + + + + } + body={

{getErrorMessage(error)}

} + actions={ + + + + } + /> + ); + } else if (isLoading && !data) { + content = ( + + + + ); + } else if (data && data.length === 0) { + content = ; + } else { + content = ( + <> + + + +

+ +

+
+
+ {addDatabaseButton} +
+ + + + + ); + } + return ( + <> + {content} + {showModal === 'add' && ( + setShowModal(null)} + reloadDatabases={resendRequest} + databases={data!} + /> + )} + {showModal === 'delete' && databaseToDelete && ( + setShowModal(null)} + /> + )} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx new file mode 100644 index 0000000000000..09767f328da50 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import { ResponseErrorBody } from '@kbn/core-http-browser'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const getErrorMessage = (error: ResponseErrorBody) => { + if (error.statusCode === 403) { + return ( + manage, + }} + /> + ); + } + + return error.message; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts new file mode 100644 index 0000000000000..517fe284874f8 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ManageProcessors } from './manage_processors'; +export { useCheckManageProcessorsPrivileges } from './use_check_manage_processors_privileges'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx new file mode 100644 index 0000000000000..d721441856b15 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useKibana } from '../../../shared_imports'; +import { UIM_MANAGE_PROCESSORS } from '../../constants'; +import { GeoipList } from './geoip_list'; + +export const ManageProcessors: React.FunctionComponent = () => { + const { services } = useKibana(); + // Track component loaded + useEffect(() => { + services.metric.trackUiMetric(UIM_MANAGE_PROCESSORS); + services.breadcrumbs.setBreadcrumbs('manage_processors'); + }, [services.metric, services.breadcrumbs]); + + return ( + <> + + + + } + /> + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts new file mode 100644 index 0000000000000..c1afa6dc94209 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useKibana } from '../../../shared_imports'; + +export const useCheckManageProcessorsPrivileges = () => { + const { services } = useKibana(); + const { isLoading, data: privilegesData } = services.api.useLoadManageProcessorsPrivileges(); + const hasPrivileges = privilegesData?.hasAllPrivileges; + return isLoading ? false : !!hasPrivileges; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 886bfcf8b9029..55456ee54e8c9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -26,7 +26,14 @@ import { import { Pipeline } from '../../../../common/types'; import { useKibana, SectionLoading } from '../../../shared_imports'; import { UIM_PIPELINES_LIST_LOAD } from '../../constants'; -import { getEditPath, getClonePath } from '../../services/navigation'; +import { + getEditPath, + getClonePath, + getCreateFromCsvPath, + getCreatePath, + getManageProcessorsPath, +} from '../../services/navigation'; +import { useCheckManageProcessorsPrivileges } from '../manage_processors'; import { EmptyList } from './empty_list'; import { PipelineTable } from './table'; @@ -54,6 +61,7 @@ export const PipelinesList: React.FunctionComponent = ({ const { data, isLoading, error, resendRequest } = services.api.useLoadPipelines(); + const hasManageProcessorsPrivileges = useCheckManageProcessorsPrivileges(); // Track component loaded useEffect(() => { services.metric.trackUiMetric(UIM_PIPELINES_LIST_LOAD); @@ -142,7 +150,7 @@ export const PipelinesList: React.FunctionComponent = ({ name: i18n.translate('xpack.ingestPipelines.list.table.createPipelineButtonLabel', { defaultMessage: 'New pipeline', }), - ...reactRouterNavigate(history, '/create'), + ...reactRouterNavigate(history, getCreatePath()), 'data-test-subj': `createNewPipeline`, }, /** @@ -152,10 +160,71 @@ export const PipelinesList: React.FunctionComponent = ({ name: i18n.translate('xpack.ingestPipelines.list.table.createPipelineFromCsvButtonLabel', { defaultMessage: 'New pipeline from CSV', }), - ...reactRouterNavigate(history, '/csv_create'), + ...reactRouterNavigate(history, getCreateFromCsvPath()), 'data-test-subj': `createPipelineFromCsv`, }, ]; + const titleActionButtons = [ + setShowPopover(false)} + button={ + setShowPopover((previousBool) => !previousBool)} + > + {i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', { + defaultMessage: 'Create pipeline', + })} + + } + panelPaddingSize="none" + repositionOnScroll + > + + , + ]; + if (services.config.enableManageProcessors && hasManageProcessorsPrivileges) { + titleActionButtons.push( + + + + ); + } + titleActionButtons.push( + + + + ); const renderFlyout = (): React.ReactNode => { if (!showFlyout) { @@ -199,51 +268,7 @@ export const PipelinesList: React.FunctionComponent = ({ defaultMessage="Use ingest pipelines to remove or transform fields, extract values from text, and enrich your data before indexing into Elasticsearch." /> } - rightSideItems={[ - setShowPopover(false)} - button={ - setShowPopover((previousBool) => !previousBool)} - > - {i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', { - defaultMessage: 'Create pipeline', - })} - - } - panelPaddingSize="none" - repositionOnScroll - > - - , - - - , - ]} + rightSideItems={titleActionButtons} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts index f687c80351075..e32245e325b15 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { HttpSetup } from '@kbn/core/public'; +import { HttpSetup, ResponseErrorBody } from '@kbn/core/public'; -import { FieldCopyAction, Pipeline } from '../../../common/types'; +import type { FieldCopyAction, GeoipDatabase, Pipeline } from '../../../common/types'; import { API_BASE_PATH } from '../../../common/constants'; import { UseRequestConfig, @@ -140,6 +140,39 @@ export class ApiService { }); return result; } + + public useLoadDatabases() { + return this.useRequest({ + path: `${API_BASE_PATH}/databases`, + method: 'get', + }); + } + + public async createDatabase(database: { + databaseType: string; + maxmind?: string; + databaseName: string; + }) { + return this.sendRequest({ + path: `${API_BASE_PATH}/databases`, + method: 'post', + body: JSON.stringify(database), + }); + } + + public async deleteDatabase(id: string) { + return this.sendRequest({ + path: `${API_BASE_PATH}/databases/${id}`, + method: 'delete', + }); + } + + public useLoadManageProcessorsPrivileges() { + return this.useRequest<{ hasAllPrivileges: boolean }>({ + path: `${API_BASE_PATH}/privileges/manage_processors`, + method: 'get', + }); + } } export const apiService = new ApiService(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts index f09b1325f7982..e8b010917cfae 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts @@ -48,6 +48,17 @@ export class BreadcrumbService { }), }, ], + manage_processors: [ + { + text: homeBreadcrumbText, + href: `/`, + }, + { + text: i18n.translate('xpack.ingestPipelines.breadcrumb.manageProcessorsLabel', { + defaultMessage: 'Manage processors', + }), + }, + ], }; private setBreadcrumbsHandler?: SetBreadcrumbs; @@ -56,7 +67,7 @@ export class BreadcrumbService { this.setBreadcrumbsHandler = setBreadcrumbsHandler; } - public setBreadcrumbs(type: 'create' | 'home' | 'edit'): void { + public setBreadcrumbs(type: 'create' | 'home' | 'edit' | 'manage_processors'): void { if (!this.setBreadcrumbsHandler) { throw new Error('Breadcrumb service has not been initialized'); } diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts index 7d3e11fea3d89..aa4f95be09b17 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts @@ -13,6 +13,8 @@ const CREATE_PATH = 'create'; const CREATE_FROM_CSV_PATH = 'csv_create'; +const MANAGE_PROCESSORS_PATH = 'manage_processors'; + const _getEditPath = (name: string, encode = true): string => { return `${BASE_PATH}${EDIT_PATH}/${encode ? encodeURIComponent(name) : name}`; }; @@ -33,12 +35,17 @@ const _getCreateFromCsvPath = (): string => { return `${BASE_PATH}${CREATE_FROM_CSV_PATH}`; }; +const _getManageProcessorsPath = (): string => { + return `${BASE_PATH}${MANAGE_PROCESSORS_PATH}`; +}; + export const ROUTES = { list: _getListPath(), edit: _getEditPath(':name', false), create: _getCreatePath(), clone: _getClonePath(':sourceName', false), createFromCsv: _getCreateFromCsvPath(), + manageProcessors: _getManageProcessorsPath(), }; export const getListPath = ({ @@ -52,3 +59,4 @@ export const getCreatePath = (): string => _getCreatePath(); export const getClonePath = ({ clonedPipelineName }: { clonedPipelineName: string }): string => _getClonePath(clonedPipelineName, true); export const getCreateFromCsvPath = (): string => _getCreateFromCsvPath(); +export const getManageProcessorsPath = (): string => _getManageProcessorsPath(); diff --git a/x-pack/plugins/ingest_pipelines/public/index.ts b/x-pack/plugins/ingest_pipelines/public/index.ts index b269245faf520..d7fb12c5477d3 100644 --- a/x-pack/plugins/ingest_pipelines/public/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/index.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { PluginInitializerContext } from '@kbn/core/public'; import { IngestPipelinesPlugin } from './plugin'; -export function plugin() { - return new IngestPipelinesPlugin(); +export function plugin(context: PluginInitializerContext) { + return new IngestPipelinesPlugin(context); } export { INGEST_PIPELINES_APP_LOCATOR, INGEST_PIPELINES_PAGES } from './locator'; diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index ae180b8378af3..75a6139e95933 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -7,11 +7,11 @@ import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; -import { CoreStart, CoreSetup, Plugin } from '@kbn/core/public'; +import type { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; -import { SetupDependencies, StartDependencies, ILicense } from './types'; +import type { SetupDependencies, StartDependencies, ILicense, Config } from './types'; import { IngestPipelinesLocatorDefinition } from './locator'; export class IngestPipelinesPlugin @@ -19,6 +19,11 @@ export class IngestPipelinesPlugin { private license: ILicense | null = null; private licensingSubscription?: Subscription; + private readonly config: Config; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } public setup(coreSetup: CoreSetup, plugins: SetupDependencies): void { const { management, usageCollection, share } = plugins; @@ -49,6 +54,9 @@ export class IngestPipelinesPlugin const unmountAppCallback = await mountManagementSection(coreSetup, { ...params, license: this.license, + config: { + enableManageProcessors: this.config.enableManageProcessors !== false, + }, }); return () => { diff --git a/x-pack/plugins/ingest_pipelines/public/types.ts b/x-pack/plugins/ingest_pipelines/public/types.ts index bfa1ac4300b3a..5b1dee11d37e0 100644 --- a/x-pack/plugins/ingest_pipelines/public/types.ts +++ b/x-pack/plugins/ingest_pipelines/public/types.ts @@ -25,3 +25,7 @@ export interface StartDependencies { licensing?: LicensingPluginStart; console?: ConsolePluginStart; } + +export interface Config { + enableManageProcessors: boolean; +} diff --git a/x-pack/plugins/ingest_pipelines/server/config.ts b/x-pack/plugins/ingest_pipelines/server/config.ts new file mode 100644 index 0000000000000..dc3dcf86a6256 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/config.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { offeringBasedSchema, schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core-plugins-server'; + +const configSchema = schema.object( + { + enableManageProcessors: offeringBasedSchema({ + // Manage processors UI is disabled in serverless; refer to the serverless.yml file as the source of truth + // We take this approach in order to have a central place (serverless.yml) for serverless config across Kibana + serverless: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type IngestPipelinesConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + enableManageProcessors: true, + }, +}; diff --git a/x-pack/plugins/ingest_pipelines/server/index.ts b/x-pack/plugins/ingest_pipelines/server/index.ts index aac84c37591db..b48d8214c1264 100644 --- a/x-pack/plugins/ingest_pipelines/server/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/index.ts @@ -5,7 +5,11 @@ * 2.0. */ -export async function plugin() { +import { PluginInitializerContext } from '@kbn/core/server'; + +export { config } from './config'; + +export async function plugin(context: PluginInitializerContext) { const { IngestPipelinesPlugin } = await import('./plugin'); - return new IngestPipelinesPlugin(); + return new IngestPipelinesPlugin(context); } diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts index ea1d9fc01c42a..85ca1691bf392 100644 --- a/x-pack/plugins/ingest_pipelines/server/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -5,17 +5,20 @@ * 2.0. */ -import { CoreSetup, Plugin } from '@kbn/core/server'; +import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import { IngestPipelinesConfigType } from './config'; import { ApiRoutes } from './routes'; import { handleEsError } from './shared_imports'; import { Dependencies } from './types'; export class IngestPipelinesPlugin implements Plugin { private readonly apiRoutes: ApiRoutes; + private readonly config: IngestPipelinesConfigType; - constructor() { + constructor(initContext: PluginInitializerContext) { this.apiRoutes = new ApiRoutes(); + this.config = initContext.config.get(); } public setup({ http }: CoreSetup, { security, features }: Dependencies) { @@ -38,6 +41,7 @@ export class IngestPipelinesPlugin implements Plugin { router, config: { isSecurityEnabled: () => security !== undefined && security.license.isEnabled(), + enableManageProcessors: this.config.enableManageProcessors !== false, }, lib: { handleEsError, diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts new file mode 100644 index 0000000000000..56fef0e159d66 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDependencies } from '../../../types'; +import { API_BASE_PATH } from '../../../../common/constants'; +import { serializeGeoipDatabase } from './serialization'; +import { normalizeDatabaseName } from './normalize_database_name'; + +const bodySchema = schema.object({ + databaseType: schema.oneOf([schema.literal('ipinfo'), schema.literal('maxmind')]), + // maxmind is only needed for "geoip" type + maxmind: schema.maybe(schema.string({ maxLength: 1000 })), + // only allow database names in sync with ES + databaseName: schema.oneOf([ + // geoip names https://github.com/elastic/elasticsearch/blob/f150e2c11df0fe3bef298c55bd867437e50f5f73/modules/ingest-geoip/src/main/java/org/elasticsearch/ingest/geoip/direct/DatabaseConfiguration.java#L58 + schema.literal('GeoIP2-Anonymous-IP'), + schema.literal('GeoIP2-City'), + schema.literal('GeoIP2-Connection-Type'), + schema.literal('GeoIP2-Country'), + schema.literal('GeoIP2-Domain'), + schema.literal('GeoIP2-Enterprise'), + schema.literal('GeoIP2-ISP'), + // ipinfo names + schema.literal('asn'), + schema.literal('country'), + schema.literal('standard_asn'), + schema.literal('standard_location'), + schema.literal('standard_privacy'), + ]), +}); + +export const registerCreateDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.post( + { + path: `${API_BASE_PATH}/databases`, + validate: { + body: bodySchema, + }, + }, + async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + const { databaseType, databaseName, maxmind } = req.body; + const serializedDatabase = serializeGeoipDatabase({ databaseType, databaseName, maxmind }); + const normalizedDatabaseName = normalizeDatabaseName(databaseName); + + try { + // TODO: Replace this request with the one below when the JS client fixed + await clusterClient.asCurrentUser.transport.request({ + method: 'PUT', + path: `/_ingest/ip_location/database/${normalizedDatabaseName}`, + body: serializedDatabase, + }); + + // This request fails because there is a bug in the JS client + // await clusterClient.asCurrentUser.ingest.putGeoipDatabase({ + // id: normalizedDatabaseName, + // body: serializedDatabase, + // }); + + return res.ok({ body: { name: databaseName, id: normalizedDatabaseName } }); + } catch (error) { + return handleEsError({ error, response: res }); + } + } + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts new file mode 100644 index 0000000000000..69dcde1436fd6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDependencies } from '../../../types'; +import { API_BASE_PATH } from '../../../../common/constants'; + +const paramsSchema = schema.object({ + database_id: schema.string(), +}); + +export const registerDeleteDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.delete( + { + path: `${API_BASE_PATH}/databases/{database_id}`, + validate: { + params: paramsSchema, + }, + }, + async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + const { database_id: databaseID } = req.params; + + try { + await clusterClient.asCurrentUser.ingest.deleteGeoipDatabase({ id: databaseID }); + + return res.ok(); + } catch (error) { + return handleEsError({ error, response: res }); + } + } + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts new file mode 100644 index 0000000000000..612b52dbd0643 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerListDatabaseRoute } from './list'; +export { registerCreateDatabaseRoute } from './create'; +export { registerDeleteDatabaseRoute } from './delete'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts new file mode 100644 index 0000000000000..b3509a5486435 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { deserializeGeoipDatabase, type GeoipDatabaseFromES } from './serialization'; +import { API_BASE_PATH } from '../../../../common/constants'; +import { RouteDependencies } from '../../../types'; + +export const registerListDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.get({ path: `${API_BASE_PATH}/databases`, validate: false }, async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + + try { + const data = (await clusterClient.asCurrentUser.ingest.getGeoipDatabase()) as { + databases: GeoipDatabaseFromES[]; + }; + + const geoipDatabases = data.databases; + + return res.ok({ body: geoipDatabases.map(deserializeGeoipDatabase) }); + } catch (error) { + const esErrorResponse = handleEsError({ error, response: res }); + if (esErrorResponse.status === 404) { + // ES returns 404 when there are no pipelines + // Instead, we return an empty array and 200 status back to the client + return res.ok({ body: [] }); + } + return esErrorResponse; + } + }); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts new file mode 100644 index 0000000000000..36f142d91a28d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const normalizeDatabaseName = (databaseName: string): string => { + return databaseName.replace(/\s+/g, '_').toLowerCase(); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts new file mode 100644 index 0000000000000..2f2c93ba5334d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GeoipDatabaseFromES { + id: string; + version: number; + modified_date_millis: number; + database: { + name: string; + // maxmind type + maxmind?: { + account_id: string; + }; + // ipinfo type + ipinfo?: {}; + // local type + local?: {}; + // web type + web?: {}; + }; +} + +interface SerializedGeoipDatabase { + name: string; + ipinfo?: {}; + local?: {}; + web?: {}; + maxmind?: { + account_id: string; + }; +} + +const getGeoipType = ({ database }: GeoipDatabaseFromES) => { + if (database.maxmind && database.maxmind.account_id) { + return 'maxmind'; + } + + if (database.ipinfo) { + return 'ipinfo'; + } + + if (database.local) { + return 'local'; + } + + if (database.web) { + return 'web'; + } + + return 'unknown'; +}; + +export const deserializeGeoipDatabase = (geoipDatabase: GeoipDatabaseFromES) => { + const { database, id } = geoipDatabase; + return { + name: database.name, + id, + type: getGeoipType(geoipDatabase), + }; +}; + +export const serializeGeoipDatabase = ({ + databaseType, + databaseName, + maxmind, +}: { + databaseType: 'maxmind' | 'ipinfo' | 'local' | 'web'; + databaseName: string; + maxmind?: string; +}): SerializedGeoipDatabase => { + const database = { name: databaseName } as SerializedGeoipDatabase; + + if (databaseType === 'maxmind') { + database.maxmind = { account_id: maxmind ?? '' }; + } + + if (databaseType === 'ipinfo') { + database.ipinfo = {}; + } + + if (databaseType === 'local') { + database.local = {}; + } + + if (databaseType === 'web') { + database.web = {}; + } + + return database; +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts index aec90d2c3a2eb..7be84d9baad87 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts @@ -20,3 +20,9 @@ export { registerSimulateRoute } from './simulate'; export { registerDocumentsRoute } from './documents'; export { registerParseCsvRoute } from './parse_csv'; + +export { + registerListDatabaseRoute, + registerCreateDatabaseRoute, + registerDeleteDatabaseRoute, +} from './database'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts index 29b282b5fbf20..87f0e3e79f07f 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts @@ -6,9 +6,14 @@ */ import { Privileges } from '@kbn/es-ui-shared-plugin/common'; +import { schema } from '@kbn/config-schema'; import { RouteDependencies } from '../../types'; import { API_BASE_PATH, APP_CLUSTER_REQUIRED_PRIVILEGES } from '../../../common/constants'; +const requiredPrivilegesMap = { + ingest_pipelines: APP_CLUSTER_REQUIRED_PRIVILEGES, + manage_processors: ['manage'], +}; const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { if (!privilegesObject[privilegeName]) { @@ -20,10 +25,18 @@ const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = export const registerPrivilegesRoute = ({ router, config }: RouteDependencies) => { router.get( { - path: `${API_BASE_PATH}/privileges`, - validate: false, + path: `${API_BASE_PATH}/privileges/{permissions_type}`, + validate: { + params: schema.object({ + permissions_type: schema.oneOf([ + schema.literal('ingest_pipelines'), + schema.literal('manage_processors'), + ]), + }), + }, }, async (ctx, req, res) => { + const permissionsType = req.params.permissions_type; const privilegesResult: Privileges = { hasAllPrivileges: true, missingPrivileges: { @@ -38,9 +51,10 @@ export const registerPrivilegesRoute = ({ router, config }: RouteDependencies) = const { client: clusterClient } = (await ctx.core).elasticsearch; + const requiredPrivileges = requiredPrivilegesMap[permissionsType]; const { has_all_requested: hasAllPrivileges, cluster } = await clusterClient.asCurrentUser.security.hasPrivileges({ - body: { cluster: APP_CLUSTER_REQUIRED_PRIVILEGES }, + body: { cluster: requiredPrivileges }, }); if (!hasAllPrivileges) { diff --git a/x-pack/plugins/ingest_pipelines/server/routes/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/index.ts index d3d74b31c1013..9a74a285fb5e4 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/index.ts @@ -16,6 +16,9 @@ import { registerSimulateRoute, registerDocumentsRoute, registerParseCsvRoute, + registerListDatabaseRoute, + registerCreateDatabaseRoute, + registerDeleteDatabaseRoute, } from './api'; export class ApiRoutes { @@ -28,5 +31,10 @@ export class ApiRoutes { registerSimulateRoute(dependencies); registerDocumentsRoute(dependencies); registerParseCsvRoute(dependencies); + if (dependencies.config.enableManageProcessors) { + registerListDatabaseRoute(dependencies); + registerCreateDatabaseRoute(dependencies); + registerDeleteDatabaseRoute(dependencies); + } } } diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts index 34c821b90e79c..8204e7f21e93d 100644 --- a/x-pack/plugins/ingest_pipelines/server/types.ts +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -19,6 +19,7 @@ export interface RouteDependencies { router: IRouter; config: { isSecurityEnabled: () => boolean; + enableManageProcessors: boolean; }; lib: { handleEsError: typeof handleEsError; diff --git a/x-pack/plugins/ingest_pipelines/tsconfig.json b/x-pack/plugins/ingest_pipelines/tsconfig.json index 7570a8f659167..5792ac1b9fda1 100644 --- a/x-pack/plugins/ingest_pipelines/tsconfig.json +++ b/x-pack/plugins/ingest_pipelines/tsconfig.json @@ -36,7 +36,9 @@ "@kbn/react-kibana-context-theme", "@kbn/unsaved-changes-prompt", "@kbn/core-http-browser-mocks", - "@kbn/shared-ux-table-persist" + "@kbn/shared-ux-table-persist", + "@kbn/core-http-browser", + "@kbn/core-plugins-server" ], "exclude": [ "target/**/*", diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts new file mode 100644 index 0000000000000..93a7ccc7d4088 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const ingestPipelines = getService('ingestPipelines'); + const url = `/api/ingest_pipelines/databases`; + const databaseName = 'GeoIP2-Anonymous-IP'; + const normalizedDatabaseName = 'geoip2-anonymous-ip'; + + describe('Manage databases', function () { + after(async () => { + await ingestPipelines.api.deleteGeoipDatabases(); + }); + + describe('Create', () => { + it('creates a geoip database when using a correct database name', async () => { + const database = { maxmind: '123456', databaseName }; + const { body } = await supertest + .post(url) + .set('kbn-xsrf', 'xxx') + .send(database) + .expect(200); + + expect(body).to.eql({ + name: databaseName, + id: normalizedDatabaseName, + }); + }); + + it('creates a geoip database when using an incorrect database name', async () => { + const database = { maxmind: '123456', databaseName: 'Test' }; + await supertest.post(url).set('kbn-xsrf', 'xxx').send(database).expect(400); + }); + }); + + describe('List', () => { + it('returns existing databases', async () => { + const { body } = await supertest.get(url).set('kbn-xsrf', 'xxx').expect(200); + expect(body).to.eql([ + { + id: normalizedDatabaseName, + name: databaseName, + type: 'maxmind', + }, + ]); + }); + }); + + describe('Delete', () => { + it('deletes a geoip database', async () => { + await supertest + .delete(`${url}/${normalizedDatabaseName}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts new file mode 100644 index 0000000000000..0afcb720dc3cd --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Ingest pipelines', () => { + loadTestFile(require.resolve('./databases')); + }); +} diff --git a/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts b/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ diff --git a/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts b/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts index e1f4b8a430314..493540afa4710 100644 --- a/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts +++ b/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts @@ -70,5 +70,20 @@ export function IngestPipelinesAPIProvider({ getService }: FtrProviderContext) { return await es.indices.delete({ index: indexName }); }, + + async deleteGeoipDatabases() { + const { databases } = await es.ingest.getGeoipDatabase(); + // Remove all geoip databases + const databaseIds = databases.map((database: { id: string }) => database.id); + + const deleteDatabase = (id: string) => + es.ingest.deleteGeoipDatabase({ + id, + }); + + return Promise.all(databaseIds.map(deleteDatabase)).catch((err) => { + log.debug(`[Cleanup error] Error deleting ES resources: ${err.message}`); + }); + }, }; } diff --git a/x-pack/test/functional/apps/ingest_pipelines/index.ts b/x-pack/test/functional/apps/ingest_pipelines/index.ts index 3c585319cfe13..1f77f5078de9f 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/index.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/index.ts @@ -11,5 +11,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { describe('Ingest pipelines app', function () { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ingest_pipelines')); + loadTestFile(require.resolve('./manage_processors')); }); }; diff --git a/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts b/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts new file mode 100644 index 0000000000000..a4951a2829fd0 --- /dev/null +++ b/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'ingestPipelines', 'savedObjects']); + const security = getService('security'); + const maxMindDatabaseName = 'GeoIP2-Anonymous-IP'; + const ipInfoDatabaseName = 'ASN'; + + // TODO: Fix flaky tests + describe.skip('Ingest Pipelines: Manage Processors', function () { + this.tags('smoke'); + before(async () => { + await security.testUser.setRoles(['manage_processors_user']); + }); + beforeEach(async () => { + await pageObjects.common.navigateToApp('ingestPipelines'); + await pageObjects.ingestPipelines.navigateToManageProcessorsPage(); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('Empty list prompt', async () => { + const promptExists = await pageObjects.ingestPipelines.geoipEmptyListPromptExists(); + expect(promptExists).to.be(true); + }); + + it('Create a MaxMind database', async () => { + await pageObjects.ingestPipelines.openCreateDatabaseModal(); + await pageObjects.ingestPipelines.fillAddDatabaseForm( + 'MaxMind', + 'GeoIP2 Anonymous IP', + '123456' + ); + await pageObjects.ingestPipelines.clickAddDatabaseButton(); + + // Wait for new row to gets displayed + await pageObjects.common.sleep(1000); + + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const databaseExists = Boolean( + databasesList.find((databaseRow) => databaseRow.includes(maxMindDatabaseName)) + ); + + expect(databaseExists).to.be(true); + }); + + it('Create an IPInfo database', async () => { + await pageObjects.ingestPipelines.openCreateDatabaseModal(); + await pageObjects.ingestPipelines.fillAddDatabaseForm('IPInfo', ipInfoDatabaseName); + await pageObjects.ingestPipelines.clickAddDatabaseButton(); + + // Wait for new row to gets displayed + await pageObjects.common.sleep(1000); + + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const databaseExists = Boolean( + databasesList.find((databaseRow) => databaseRow.includes(ipInfoDatabaseName)) + ); + + expect(databaseExists).to.be(true); + }); + + it('Table contains database name and maxmind type', async () => { + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const maxMindDatabaseRow = databasesList.find((database) => + database.includes(maxMindDatabaseName) + ); + expect(maxMindDatabaseRow).to.contain(maxMindDatabaseName); + expect(maxMindDatabaseRow).to.contain('MaxMind'); + + const ipInfoDatabaseRow = databasesList.find((database) => + database.includes(ipInfoDatabaseName) + ); + expect(ipInfoDatabaseRow).to.contain(ipInfoDatabaseName); + expect(ipInfoDatabaseRow).to.contain('IPInfo'); + }); + + it('Modal to delete a database', async () => { + // Delete both databases + await pageObjects.ingestPipelines.deleteDatabase(0); + await pageObjects.ingestPipelines.deleteDatabase(0); + const promptExists = await pageObjects.ingestPipelines.geoipEmptyListPromptExists(); + expect(promptExists).to.be(true); + }); + }); +}; diff --git a/x-pack/test/functional/config.base.js b/x-pack/test/functional/config.base.js index 8d1e875dabccc..b35d1f6b6673c 100644 --- a/x-pack/test/functional/config.base.js +++ b/x-pack/test/functional/config.base.js @@ -640,6 +640,20 @@ export default async function ({ readConfigFile }) { ], }, + manage_processors_user: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: [ + { + feature: { + advancedSettings: ['read'], + }, + spaces: ['*'], + }, + ], + }, + license_management_user: { elasticsearch: { cluster: ['manage'], diff --git a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts index 218a34e5c1ae2..b62d34b114f4b 100644 --- a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts +++ b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts @@ -7,12 +7,14 @@ import path from 'path'; import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function IngestPipelinesPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['header', 'common']); const aceEditor = getService('aceEditor'); + const retry = getService('retry'); return { async sectionHeadingText() { @@ -113,5 +115,56 @@ export function IngestPipelinesPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('tablePaginationPopoverButton'); await testSubjects.click(`tablePagination-50-rows`); }, + + async navigateToManageProcessorsPage() { + await testSubjects.click('manageProcessorsLink'); + await retry.waitFor('Manage Processors page title to be displayed', async () => { + return await testSubjects.isDisplayed('manageProcessorsTitle'); + }); + }, + + async geoipEmptyListPromptExists() { + return await testSubjects.exists('geoipEmptyListPrompt'); + }, + + async openCreateDatabaseModal() { + await testSubjects.click('addGeoipDatabaseButton'); + }, + + async fillAddDatabaseForm(databaseType: string, databaseName: string, maxmind?: string) { + await testSubjects.setValue('databaseTypeSelect', databaseType); + + // Wait for the rest of the fields to get displayed + await pageObjects.common.sleep(1000); + expect(await testSubjects.exists('databaseNameSelect')).to.be(true); + + if (maxmind) { + await testSubjects.setValue('maxmindField', maxmind); + } + await testSubjects.setValue('databaseNameSelect', databaseName); + }, + + async clickAddDatabaseButton() { + // Wait for button to get enabled + await pageObjects.common.sleep(1000); + await testSubjects.click('addGeoipDatabaseSubmit'); + }, + + async getGeoipDatabases() { + const databases = await testSubjects.findAll('geoipDatabaseListRow'); + + const getDatabaseRow = async (database: WebElementWrapper) => { + return await database.getVisibleText(); + }; + + return await Promise.all(databases.map((database) => getDatabaseRow(database))); + }, + + async deleteDatabase(index: number) { + const deleteButtons = await testSubjects.findAll('deleteGeoipDatabaseButton'); + await deleteButtons.at(index)?.click(); + await testSubjects.setValue('geoipDatabaseConfirmation', 'delete'); + await testSubjects.click('deleteGeoipDatabaseSubmit'); + }, }; } From 9c2a0418f51bb87f130c3ac7d139bad4d1aa7cc5 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Tue, 15 Oct 2024 21:01:02 +0300 Subject: [PATCH 07/31] [Cloud Security] 3P callout displayed in tables (#196335) --- .../pages/configurations/configurations.tsx | 4 ++ .../public/pages/findings/findings.tsx | 34 ++------------- .../third_party_integrations_callout.tsx | 41 +++++++++++++++++++ .../pages/vulnerabilities/vulnerabilities.tsx | 4 ++ 4 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/findings/third_party_integrations_callout.tsx diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx index d070d2cd9ec4b..4cc5ea679ba80 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx @@ -12,6 +12,8 @@ import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_ import { CDR_MISCONFIGURATIONS_DATA_VIEW_ID_PREFIX } from '@kbn/cloud-security-posture-common'; import { findingsNavigation } from '@kbn/cloud-security-posture'; import { useDataView } from '@kbn/cloud-security-posture/src/hooks/use_data_view'; +import { EuiSpacer } from '@elastic/eui'; +import { ThirdPartyIntegrationsCallout } from '../findings/third_party_integrations_callout'; import { NoFindingsStates } from '../../components/no_findings_states'; import { CloudPosturePage, defaultLoadingRenderer } from '../../components/cloud_posture_page'; import { cloudPosturePages } from '../../common/navigation/constants'; @@ -45,6 +47,8 @@ export const Configurations = () => { return ( + + { const history = useHistory(); const location = useLocation(); - const wizAddIntegrationLink = useAdd3PIntegrationRoute('wiz'); - const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage( - LOCAL_STORAGE_3P_INTEGRATIONS_CALLOUT_KEY - ); + // restore the users most recent tab selection const [lastTabSelected, setLastTabSelected] = useLocalStorage( LOCAL_STORAGE_FINDINGS_LAST_SELECTED_TAB_KEY @@ -109,26 +101,6 @@ export const Findings = () => { - {!userHasDismissedCallout && ( - <> - setUserHasDismissedCallout(true)} - > - - - - - - - )} { + const wizAddIntegrationLink = useAdd3PIntegrationRoute('wiz'); + const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage( + LOCAL_STORAGE_3P_INTEGRATIONS_CALLOUT_KEY + ); + + if (userHasDismissedCallout) return null; + + return ( + setUserHasDismissedCallout(true)} + > + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index 659d1c9d5e245..90ffc4849c0b7 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -9,6 +9,8 @@ import { Routes, Route } from '@kbn/shared-ux-router'; import { findingsNavigation } from '@kbn/cloud-security-posture'; import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api'; import { useDataView } from '@kbn/cloud-security-posture/src/hooks/use_data_view'; +import { EuiSpacer } from '@elastic/eui'; +import { ThirdPartyIntegrationsCallout } from '../findings/third_party_integrations_callout'; import { VULNERABILITIES_PAGE } from './test_subjects'; import { CDR_VULNERABILITIES_DATA_VIEW_ID_PREFIX } from '../../../common/constants'; import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states'; @@ -34,6 +36,8 @@ export const Vulnerabilities = () => { return ( + +
Date: Tue, 15 Oct 2024 13:07:10 -0500 Subject: [PATCH 08/31] Revert "[Security Solution] [Attack discovery] Output chunking / refinement, LangGraph migration, and evaluation improvements (#195669)" This reverts commit 2c21adb8faafc0016ad7a6591837118f6bdf0907. --- .../get_raw_data_or_default/index.test.ts | 28 - .../helpers/get_raw_data_or_default/index.ts | 13 - .../helpers/is_raw_data_valid/index.test.ts | 51 - .../alerts/helpers/is_raw_data_valid/index.ts | 11 - .../size_is_out_of_range/index.test.ts | 47 - .../helpers/size_is_out_of_range/index.ts | 12 - .../impl/alerts/helpers/types.ts | 14 - .../attack_discovery/common_attributes.gen.ts | 4 +- .../common_attributes.schema.yaml | 2 + .../evaluation/post_evaluate_route.gen.ts | 2 - .../post_evaluate_route.schema.yaml | 4 - .../kbn-elastic-assistant-common/index.ts | 16 - .../alerts_settings/alerts_settings.tsx | 3 +- .../alerts_settings_management.tsx | 1 - .../evaluation_settings.tsx | 64 +- .../evaluation_settings/translations.ts | 30 - .../impl/assistant_context/constants.tsx | 5 - .../impl/assistant_context/index.tsx | 5 +- .../impl/knowledge_base/alerts_range.tsx | 64 +- .../packages/kbn-elastic-assistant/index.ts | 20 - x-pack/plugins/elastic_assistant/README.md | 10 +- .../docs/img/default_assistant_graph.png | Bin 29798 -> 30104 bytes .../img/default_attack_discovery_graph.png | Bin 22551 -> 0 bytes .../scripts/draw_graph_script.ts | 46 +- .../__mocks__/attack_discovery_schema.mock.ts | 2 +- .../server/__mocks__/data_clients.mock.ts | 2 +- .../server/__mocks__/request_context.ts | 2 +- .../server/__mocks__/response.ts | 2 +- .../create_attack_discovery.test.ts | 4 +- .../create_attack_discovery.ts | 4 +- .../field_maps_configuration.ts | 0 .../find_all_attack_discoveries.ts | 4 +- ...d_attack_discovery_by_connector_id.test.ts | 2 +- .../find_attack_discovery_by_connector_id.ts | 4 +- .../get_attack_discovery.test.ts | 2 +- .../attack_discovery}/get_attack_discovery.ts | 4 +- .../attack_discovery}/index.ts | 15 +- .../attack_discovery}/transforms.ts | 2 +- .../attack_discovery}/types.ts | 4 +- .../update_attack_discovery.test.ts | 4 +- .../update_attack_discovery.ts | 6 +- .../server/ai_assistant_service/index.ts | 4 +- .../evaluation/__mocks__/mock_examples.ts | 55 - .../evaluation/__mocks__/mock_runs.ts | 53 - .../attack_discovery/evaluation/constants.ts | 911 ----------- .../evaluation/example_input/index.test.ts | 75 - .../evaluation/example_input/index.ts | 52 - .../get_default_prompt_template/index.test.ts | 42 - .../get_default_prompt_template/index.ts | 33 - .../index.test.ts | 125 -- .../index.ts | 29 - .../index.test.ts | 117 -- .../index.ts | 27 - .../get_custom_evaluator/index.test.ts | 98 -- .../helpers/get_custom_evaluator/index.ts | 69 - .../index.test.ts | 79 - .../index.ts | 39 - .../helpers/get_evaluator_llm/index.test.ts | 161 -- .../helpers/get_evaluator_llm/index.ts | 65 - .../get_graph_input_overrides/index.test.ts | 121 -- .../get_graph_input_overrides/index.ts | 29 - .../lib/attack_discovery/evaluation/index.ts | 122 -- .../evaluation/run_evaluations/index.ts | 113 -- .../constants.ts | 21 - .../index.test.ts | 22 - .../get_generate_or_end_decision/index.ts | 9 - .../edges/generate_or_end/index.test.ts | 72 - .../edges/generate_or_end/index.ts | 38 - .../index.test.ts | 43 - .../index.ts | 28 - .../helpers/get_should_end/index.test.ts | 60 - .../helpers/get_should_end/index.ts | 16 - .../generate_or_refine_or_end/index.test.ts | 118 -- .../edges/generate_or_refine_or_end/index.ts | 66 - .../edges/helpers/get_has_results/index.ts | 11 - .../helpers/get_has_zero_alerts/index.ts | 12 - .../get_refine_or_end_decision/index.ts | 25 - .../helpers/get_should_end/index.ts | 16 - .../edges/refine_or_end/index.ts | 61 - .../get_retrieve_or_generate/index.ts | 13 - .../index.ts | 36 - .../index.ts | 14 - .../helpers/get_max_retries_reached/index.ts | 14 - .../default_attack_discovery_graph/index.ts | 122 -- ...en_and_acknowledged_alerts_qery_results.ts | 25 - ...n_and_acknowledged_alerts_query_results.ts | 1396 ----------------- .../discard_previous_generations/index.ts | 30 - .../get_alerts_context_prompt/index.ts | 22 - .../get_anonymized_alerts_from_state/index.ts | 11 - .../get_use_unrefined_results/index.ts | 27 - .../nodes/generate/index.ts | 154 -- .../nodes/generate/schema/index.ts | 84 - .../index.ts | 20 - .../nodes/helpers/extract_json/index.test.ts | 67 - .../nodes/helpers/extract_json/index.ts | 17 - .../generations_are_repeating/index.test.tsx | 90 -- .../generations_are_repeating/index.tsx | 25 - .../index.ts | 34 - .../nodes/helpers/get_combined/index.ts | 14 - .../index.ts | 43 - .../helpers/get_continue_prompt/index.ts | 15 - .../index.ts | 9 - .../helpers/get_output_parser/index.test.ts | 31 - .../nodes/helpers/get_output_parser/index.ts | 13 - .../helpers/parse_combined_or_throw/index.ts | 53 - .../helpers/response_is_hallucinated/index.ts | 9 - .../discard_previous_refinements/index.ts | 30 - .../get_combined_refine_prompt/index.ts | 48 - .../get_default_refine_prompt/index.ts | 11 - .../get_use_unrefined_results/index.ts | 17 - .../nodes/refine/index.ts | 166 -- .../anonymized_alerts_retriever/index.ts | 74 - .../nodes/retriever/index.ts | 70 - .../state/index.ts | 86 - .../default_attack_discovery_graph/types.ts | 28 - .../server/lib/langchain/graphs/index.ts | 35 +- .../cancel_attack_discovery.test.ts | 24 +- .../cancel => }/cancel_attack_discovery.ts | 10 +- .../{get => }/get_attack_discovery.test.ts | 25 +- .../{get => }/get_attack_discovery.ts | 8 +- .../routes/attack_discovery/helpers.test.ts | 805 ++++++++++ .../attack_discovery/{helpers => }/helpers.ts | 231 ++- .../attack_discovery/helpers/helpers.test.ts | 273 ---- .../post/helpers/handle_graph_error/index.tsx | 73 - .../invoke_attack_discovery_graph/index.tsx | 127 -- .../helpers/request_is_valid/index.test.tsx | 87 - .../post/helpers/request_is_valid/index.tsx | 33 - .../throw_if_error_counts_exceeded/index.ts | 44 - .../translations.ts | 28 - .../{post => }/post_attack_discovery.test.ts | 40 +- .../{post => }/post_attack_discovery.ts | 80 +- .../evaluate/get_graphs_from_names/index.ts | 35 - .../server/routes/evaluate/post_evaluate.ts | 43 +- .../server/routes/evaluate/utils.ts | 2 +- .../elastic_assistant/server/routes/index.ts | 4 +- .../server/routes/register_routes.ts | 6 +- .../plugins/elastic_assistant/server/types.ts | 4 +- .../actionable_summary/index.tsx | 43 +- .../attack_discovery_panel/index.tsx | 11 +- .../attack_discovery_panel/title/index.tsx | 27 +- .../get_attack_discovery_markdown.ts | 2 +- .../attack_discovery/hooks/use_poll_api.tsx | 6 +- .../empty_prompt/animated_counter/index.tsx | 2 +- .../pages/empty_prompt/index.test.tsx | 72 +- .../pages/empty_prompt/index.tsx | 29 +- .../helpers/show_empty_states/index.ts | 36 - .../pages/empty_states/index.test.tsx | 33 +- .../pages/empty_states/index.tsx | 44 +- .../attack_discovery/pages/failure/index.tsx | 48 +- .../pages/failure/translations.ts | 13 +- .../attack_discovery/pages/generate/index.tsx | 36 - .../pages/header/index.test.tsx | 13 - .../attack_discovery/pages/header/index.tsx | 16 +- .../settings_modal/alerts_settings/index.tsx | 77 - .../header/settings_modal/footer/index.tsx | 57 - .../pages/header/settings_modal/index.tsx | 160 -- .../settings_modal/is_tour_enabled/index.ts | 18 - .../header/settings_modal/translations.ts | 81 - .../attack_discovery/pages/helpers.test.ts | 4 - .../public/attack_discovery/pages/helpers.ts | 31 +- .../public/attack_discovery/pages/index.tsx | 104 +- .../pages/loading_callout/index.test.tsx | 3 +- .../pages/loading_callout/index.tsx | 13 +- .../get_loading_callout_alerts_count/index.ts | 24 - .../loading_messages/index.test.tsx | 4 +- .../loading_messages/index.tsx | 16 +- .../pages/no_alerts/index.test.tsx | 2 +- .../pages/no_alerts/index.tsx | 17 +- .../attack_discovery/pages/results/index.tsx | 112 -- .../use_attack_discovery/helpers.test.ts | 25 +- .../use_attack_discovery/helpers.ts | 11 +- .../use_attack_discovery/index.test.tsx | 33 +- .../use_attack_discovery/index.tsx | 17 +- .../attack_discovery_tool.test.ts | 340 ++++ .../attack_discovery/attack_discovery_tool.ts | 115 ++ .../get_anonymized_alerts.test.ts} | 18 +- .../get_anonymized_alerts.ts} | 14 +- .../get_attack_discovery_prompt.test.ts} | 17 +- .../get_attack_discovery_prompt.ts | 20 + .../get_output_parser.test.ts | 31 + .../attack_discovery/get_output_parser.ts | 80 + .../server/assistant/tools/index.ts | 2 + .../tools}/mock/mock_anonymization_fields.ts | 0 ...pen_and_acknowledged_alerts_query.test.ts} | 2 +- ...get_open_and_acknowledged_alerts_query.ts} | 7 +- .../helpers.test.ts | 117 ++ .../open_and_acknowledged_alerts/helpers.ts | 22 + .../open_and_acknowledged_alerts_tool.test.ts | 3 +- .../open_and_acknowledged_alerts_tool.ts | 10 +- .../plugins/security_solution/tsconfig.json | 1 + 190 files changed, 2148 insertions(+), 8378 deletions(-) delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts delete mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts delete mode 100644 x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/create_attack_discovery => ai_assistant_data_clients/attack_discovery}/create_attack_discovery.test.ts (94%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/create_attack_discovery => ai_assistant_data_clients/attack_discovery}/create_attack_discovery.ts (95%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/field_maps_configuration => ai_assistant_data_clients/attack_discovery}/field_maps_configuration.ts (100%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/find_all_attack_discoveries => ai_assistant_data_clients/attack_discovery}/find_all_attack_discoveries.ts (92%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/find_attack_discovery_by_connector_id => ai_assistant_data_clients/attack_discovery}/find_attack_discovery_by_connector_id.test.ts (95%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/find_attack_discovery_by_connector_id => ai_assistant_data_clients/attack_discovery}/find_attack_discovery_by_connector_id.ts (93%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/get_attack_discovery => ai_assistant_data_clients/attack_discovery}/get_attack_discovery.test.ts (95%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/get_attack_discovery => ai_assistant_data_clients/attack_discovery}/get_attack_discovery.ts (93%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence => ai_assistant_data_clients/attack_discovery}/index.ts (92%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/transforms => ai_assistant_data_clients/attack_discovery}/transforms.ts (98%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence => ai_assistant_data_clients/attack_discovery}/types.ts (93%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/update_attack_discovery => ai_assistant_data_clients/attack_discovery}/update_attack_discovery.test.ts (97%) rename x-pack/plugins/elastic_assistant/server/{lib/attack_discovery/persistence/update_attack_discovery => ai_assistant_data_clients/attack_discovery}/update_attack_discovery.ts (95%) delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{post/cancel => }/cancel_attack_discovery.test.ts (80%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{post/cancel => }/cancel_attack_discovery.ts (91%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{get => }/get_attack_discovery.test.ts (85%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{get => }/get_attack_discovery.ts (92%) create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{helpers => }/helpers.ts (55%) delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{post => }/post_attack_discovery.test.ts (79%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{post => }/post_attack_discovery.ts (79%) delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts delete mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts rename x-pack/plugins/{elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts => security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts} (90%) rename x-pack/plugins/{elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts => security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts} (77%) rename x-pack/plugins/{elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts => security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts} (70%) create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts rename x-pack/plugins/{elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph => security_solution/server/assistant/tools}/mock/mock_anonymization_fields.ts (100%) rename x-pack/{packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts => plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts} (96%) rename x-pack/{packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts => plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts} (87%) create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts deleted file mode 100644 index 899b156d21767..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getRawDataOrDefault } from '.'; - -describe('getRawDataOrDefault', () => { - it('returns the raw data when it is valid', () => { - const rawData = { - field1: [1, 2, 3], - field2: ['a', 'b', 'c'], - }; - - expect(getRawDataOrDefault(rawData)).toEqual(rawData); - }); - - it('returns an empty object when the raw data is invalid', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(getRawDataOrDefault(rawData)).toEqual({}); - }); -}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts deleted file mode 100644 index edbe320c95305..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isRawDataValid } from '../is_raw_data_valid'; -import type { MaybeRawData } from '../types'; - -/** Returns the raw data if it valid, or a default if it's not */ -export const getRawDataOrDefault = (rawData: MaybeRawData): Record => - isRawDataValid(rawData) ? rawData : {}; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts deleted file mode 100644 index cc205250e84db..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isRawDataValid } from '.'; - -describe('isRawDataValid', () => { - it('returns true for valid raw data', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns true when a field array is empty', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - field3: [], // the Fields API may return an empty array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when a field does not have an array of values', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(isRawDataValid(rawData)).toBe(false); - }); - - it('returns true for empty raw data', () => { - const rawData = {}; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when raw data is an unexpected type', () => { - const rawData = 1234; - - // @ts-expect-error - expect(isRawDataValid(rawData)).toBe(false); - }); -}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts deleted file mode 100644 index 1a9623b15ea98..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { MaybeRawData } from '../types'; - -export const isRawDataValid = (rawData: MaybeRawData): rawData is Record => - typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts deleted file mode 100644 index b118a5c94b26e..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { sizeIsOutOfRange } from '.'; -import { MAX_SIZE, MIN_SIZE } from '../types'; - -describe('sizeIsOutOfRange', () => { - it('returns true when size is undefined', () => { - const size = undefined; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is less than MIN_SIZE', () => { - const size = MIN_SIZE - 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is greater than MAX_SIZE', () => { - const size = MAX_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns false when size is exactly MIN_SIZE', () => { - const size = MIN_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is exactly MAX_SIZE', () => { - const size = MAX_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is within the valid range', () => { - const size = MIN_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); -}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts deleted file mode 100644 index b2a93b79cbb42..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { MAX_SIZE, MIN_SIZE } from '../types'; - -/** Return true if the provided size is out of range */ -export const sizeIsOutOfRange = (size?: number): boolean => - size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts deleted file mode 100644 index 5c81c99ce5732..0000000000000 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -export const MIN_SIZE = 10; -export const MAX_SIZE = 10000; - -/** currently the same shape as "fields" property in the ES response */ -export type MaybeRawData = SearchResponse['fields'] | undefined; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts index 8ade6084fd7de..9599e8596e553 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts @@ -39,7 +39,7 @@ export const AttackDiscovery = z.object({ /** * A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax */ - entitySummaryMarkdown: z.string().optional(), + entitySummaryMarkdown: z.string(), /** * An array of MITRE ATT&CK tactic for the attack discovery */ @@ -55,7 +55,7 @@ export const AttackDiscovery = z.object({ /** * The time the attack discovery was generated */ - timestamp: NonEmptyString.optional(), + timestamp: NonEmptyString, }); /** diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml index 3adf2f7836804..dcb72147f9408 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml @@ -12,7 +12,9 @@ components: required: - 'alertIds' - 'detailsMarkdown' + - 'entitySummaryMarkdown' - 'summaryMarkdown' + - 'timestamp' - 'title' properties: alertIds: diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts index a0cbc22282c7b..b6d51b9bea3fc 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts @@ -22,12 +22,10 @@ export type PostEvaluateBody = z.infer; export const PostEvaluateBody = z.object({ graphs: z.array(z.string()), datasetName: z.string(), - evaluatorConnectorId: z.string().optional(), connectorIds: z.array(z.string()), runName: z.string().optional(), alertsIndexPattern: z.string().optional().default('.alerts-security.alerts-default'), langSmithApiKey: z.string().optional(), - langSmithProject: z.string().optional(), replacements: Replacements.optional().default({}), size: z.number().optional().default(20), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml index 071d80156890b..d0bec37344165 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml @@ -61,8 +61,6 @@ components: type: string datasetName: type: string - evaluatorConnectorId: - type: string connectorIds: type: array items: @@ -74,8 +72,6 @@ components: default: ".alerts-security.alerts-default" langSmithApiKey: type: string - langSmithProject: - type: string replacements: $ref: "../conversations/common_attributes.schema.yaml#/components/schemas/Replacements" default: {} diff --git a/x-pack/packages/kbn-elastic-assistant-common/index.ts b/x-pack/packages/kbn-elastic-assistant-common/index.ts index 41ed86dacd9db..d8b4858d3ba8b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/index.ts @@ -25,19 +25,3 @@ export { export { transformRawData } from './impl/data_anonymization/transform_raw_data'; export { parseBedrockBuffer, handleBedrockChunk } from './impl/utils/bedrock'; export * from './constants'; - -/** currently the same shape as "fields" property in the ES response */ -export { type MaybeRawData } from './impl/alerts/helpers/types'; - -/** - * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. - * - * The alerts are ordered by risk score, and then from the most recent to the oldest. - */ -export { getOpenAndAcknowledgedAlertsQuery } from './impl/alerts/get_open_and_acknowledged_alerts_query'; - -/** Returns the raw data if it valid, or a default if it's not */ -export { getRawDataOrDefault } from './impl/alerts/helpers/get_raw_data_or_default'; - -/** Return true if the provided size is out of range */ -export { sizeIsOutOfRange } from './impl/alerts/helpers/size_is_out_of_range'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx index 3b48c8d0861c5..60078178a1771 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx @@ -16,7 +16,7 @@ import * as i18n from '../../../knowledge_base/translations'; export const MIN_LATEST_ALERTS = 10; export const MAX_LATEST_ALERTS = 100; export const TICK_INTERVAL = 10; -export const RANGE_CONTAINER_WIDTH = 600; // px +export const RANGE_CONTAINER_WIDTH = 300; // px const LABEL_WRAPPER_MIN_WIDTH = 95; // px interface Props { @@ -52,7 +52,6 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx index 7a3998879078d..1a6f826bd415f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx @@ -40,7 +40,6 @@ export const AlertsSettingsManagement: React.FC = React.memo( knowledgeBase={knowledgeBase} setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} compressed={false} - value={knowledgeBase.latestAlerts} /> ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx index ffbcad48d1cac..cefc008eba992 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx @@ -17,34 +17,28 @@ import { EuiComboBox, EuiButton, EuiComboBoxOptionOption, - EuiComboBoxSingleSelectionShape, EuiTextColor, EuiFieldText, - EuiFieldNumber, EuiFlexItem, EuiFlexGroup, EuiLink, EuiPanel, } from '@elastic/eui'; + import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { GetEvaluateResponse, PostEvaluateRequestBodyInput, } from '@kbn/elastic-assistant-common'; -import { isEmpty } from 'lodash/fp'; - import * as i18n from './translations'; import { useAssistantContext } from '../../../assistant_context'; -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '../../../assistant_context/constants'; import { useLoadConnectors } from '../../../connectorland/use_load_connectors'; import { getActionTypeTitle, getGenAiConfig } from '../../../connectorland/helpers'; import { PRECONFIGURED_CONNECTOR } from '../../../connectorland/translations'; import { usePerformEvaluation } from '../../api/evaluate/use_perform_evaluation'; import { useEvaluationData } from '../../api/evaluate/use_evaluation_data'; -const AS_PLAIN_TEXT: EuiComboBoxSingleSelectionShape = { asPlainText: true }; - /** * Evaluation Settings -- development-only feature for evaluating models */ @@ -127,18 +121,6 @@ export const EvaluationSettings: React.FC = React.memo(() => { }, [setSelectedModelOptions] ); - - const [selectedEvaluatorModel, setSelectedEvaluatorModel] = useState< - Array> - >([]); - - const onSelectedEvaluatorModelChange = useCallback( - (selected: Array>) => setSelectedEvaluatorModel(selected), - [] - ); - - const [size, setSize] = useState(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); - const visColorsBehindText = euiPaletteComplementary(connectors?.length ?? 0); const modelOptions = useMemo(() => { return ( @@ -188,40 +170,19 @@ export const EvaluationSettings: React.FC = React.memo(() => { // Perform Evaluation Button const handlePerformEvaluation = useCallback(async () => { - const evaluatorConnectorId = - selectedEvaluatorModel[0]?.key != null - ? { evaluatorConnectorId: selectedEvaluatorModel[0].key } - : {}; - - const langSmithApiKey = isEmpty(traceOptions.langSmithApiKey) - ? undefined - : traceOptions.langSmithApiKey; - - const langSmithProject = isEmpty(traceOptions.langSmithProject) - ? undefined - : traceOptions.langSmithProject; - const evalParams: PostEvaluateRequestBodyInput = { connectorIds: selectedModelOptions.flatMap((option) => option.key ?? []).sort(), graphs: selectedGraphOptions.map((option) => option.label).sort(), datasetName: selectedDatasetOptions[0]?.label, - ...evaluatorConnectorId, - langSmithApiKey, - langSmithProject, runName, - size: Number(size), }; performEvaluation(evalParams); }, [ performEvaluation, runName, selectedDatasetOptions, - selectedEvaluatorModel, selectedGraphOptions, selectedModelOptions, - size, - traceOptions.langSmithApiKey, - traceOptions.langSmithProject, ]); const getSection = (title: string, description: string) => ( @@ -394,29 +355,6 @@ export const EvaluationSettings: React.FC = React.memo(() => { onChange={onGraphOptionsChange} /> - - - - - - - setSize(e.target.value)} value={size} /> - diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts index 26eddb8a223c7..62902d0f14095 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts @@ -78,36 +78,6 @@ export const CONNECTORS_LABEL = i18n.translate( } ); -export const EVALUATOR_MODEL = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelLabel', - { - defaultMessage: 'Evaluator model (optional)', - } -); - -export const DEFAULT_MAX_ALERTS = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsLabel', - { - defaultMessage: 'Default max alerts', - } -); - -export const EVALUATOR_MODEL_DESCRIPTION = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelDescription', - { - defaultMessage: - 'Judge the quality of all predictions using a single model. (Default: use the same model as the connector)', - } -); - -export const DEFAULT_MAX_ALERTS_DESCRIPTION = i18n.translate( - 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsDescription', - { - defaultMessage: - 'The default maximum number of alerts to send as context, which may be overridden by the Example input', - } -); - export const CONNECTORS_DESCRIPTION = i18n.translate( 'xpack.elasticAssistant.assistant.settings.evaluationSettings.connectorsDescription', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx index 92a2a3df2683b..be7724d882278 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -10,9 +10,7 @@ import { KnowledgeBaseConfig } from '../assistant/types'; export const ATTACK_DISCOVERY_STORAGE_KEY = 'attackDiscovery'; export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId'; -export const MAX_ALERTS_LOCAL_STORAGE_KEY = 'maxAlerts'; export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase'; -export const SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY = 'showSettingsTour'; export const STREAMING_LOCAL_STORAGE_KEY = 'streaming'; export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions'; export const CONVERSATION_TABLE_SESSION_STORAGE_KEY = 'conversationTable'; @@ -23,9 +21,6 @@ export const ANONYMIZATION_TABLE_SESSION_STORAGE_KEY = 'anonymizationTable'; /** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */ export const DEFAULT_LATEST_ALERTS = 20; -/** The default maximum number of alerts to be sent as context when generating Attack discoveries */ -export const DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS = 200; - export const DEFAULT_KNOWLEDGE_BASE_SETTINGS: KnowledgeBaseConfig = { latestAlerts: DEFAULT_LATEST_ALERTS, }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 2319bf67de89a..c7b15f681a717 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -262,10 +262,7 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, - knowledgeBase: { - ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, - ...localStorageKnowledgeBase, - }, + knowledgeBase: { ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, ...localStorageKnowledgeBase }, promptContexts, navigateToApp, nameSpace, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx index 6cfa60eff282d..63bd86121dcc1 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -7,7 +7,7 @@ import { EuiRange, useGeneratedHtmlId } from '@elastic/eui'; import { css } from '@emotion/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { MAX_LATEST_ALERTS, MIN_LATEST_ALERTS, @@ -16,57 +16,35 @@ import { import { KnowledgeBaseConfig } from '../assistant/types'; import { ALERTS_RANGE } from './translations'; -export type SingleRangeChangeEvent = - | React.ChangeEvent - | React.KeyboardEvent - | React.MouseEvent; - interface Props { + knowledgeBase: KnowledgeBaseConfig; + setUpdatedKnowledgeBaseSettings: React.Dispatch>; compressed?: boolean; - maxAlerts?: number; - minAlerts?: number; - onChange?: (e: SingleRangeChangeEvent) => void; - knowledgeBase?: KnowledgeBaseConfig; - setUpdatedKnowledgeBaseSettings?: React.Dispatch>; - step?: number; - value: string | number; } const MAX_ALERTS_RANGE_WIDTH = 649; // px export const AlertsRange: React.FC = React.memo( - ({ - compressed = true, - knowledgeBase, - maxAlerts = MAX_LATEST_ALERTS, - minAlerts = MIN_LATEST_ALERTS, - onChange, - setUpdatedKnowledgeBaseSettings, - step = TICK_INTERVAL, - value, - }) => { + ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, compressed = true }) => { const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' }); - const handleOnChange = useCallback( - (e: SingleRangeChangeEvent) => { - if (knowledgeBase != null && setUpdatedKnowledgeBaseSettings != null) { - setUpdatedKnowledgeBaseSettings({ - ...knowledgeBase, - latestAlerts: Number(e.currentTarget.value), - }); - } - - if (onChange != null) { - onChange(e); - } - }, - [knowledgeBase, onChange, setUpdatedKnowledgeBaseSettings] - ); - return ( + setUpdatedKnowledgeBaseSettings({ + ...knowledgeBase, + latestAlerts: Number(e.currentTarget.value), + }) + } + showTicks + step={TICK_INTERVAL} + value={knowledgeBase.latestAlerts} css={css` max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px; & .euiRangeTrack { @@ -74,14 +52,6 @@ export const AlertsRange: React.FC = React.memo( margin-inline-end: 0; } `} - data-test-subj="alertsRange" - id={inputRangeSliderId} - max={maxAlerts} - min={minAlerts} - onChange={handleOnChange} - showTicks - step={step} - value={value} /> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index 7ec65c9601268..0baff57648cc8 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -77,17 +77,10 @@ export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_ava export { ConnectorSelectorInline } from './impl/connectorland/connector_selector_inline/connector_selector_inline'; export { - /** The Attack discovery local storage key */ ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, - /** The default maximum number of alerts to be sent as context when generating Attack discoveries */ - DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, DEFAULT_LATEST_ALERTS, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, - /** The local storage key that specifies the maximum number of alerts to send as context */ - MAX_ALERTS_LOCAL_STORAGE_KEY, - /** The local storage key that specifies whether the settings tour should be shown */ - SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, } from './impl/assistant_context/constants'; export { useLoadConnectors } from './impl/connectorland/use_load_connectors'; @@ -147,16 +140,3 @@ export { mergeBaseWithPersistedConversations } from './impl/assistant/helpers'; export { UpgradeButtons } from './impl/upgrade/upgrade_buttons'; export { getUserConversations, getPrompts, bulkUpdatePrompts } from './impl/assistant/api'; - -export { - /** A range slider component, typically used to configure the number of alerts sent as context */ - AlertsRange, - /** This event occurs when the `AlertsRange` slider is changed */ - type SingleRangeChangeEvent, -} from './impl/knowledge_base/alerts_range'; -export { - /** A label instructing the user to send fewer alerts */ - SELECT_FEWER_ALERTS, - /** Your anonymization settings will apply to these alerts (label) */ - YOUR_ANONYMIZATION_SETTINGS, -} from './impl/knowledge_base/translations'; diff --git a/x-pack/plugins/elastic_assistant/README.md b/x-pack/plugins/elastic_assistant/README.md index 8cf2c0b8903dd..2a1e47c177591 100755 --- a/x-pack/plugins/elastic_assistant/README.md +++ b/x-pack/plugins/elastic_assistant/README.md @@ -10,21 +10,15 @@ Maintained by the Security Solution team ## Graph structure -### Default Assistant graph - ![DefaultAssistantGraph](./docs/img/default_assistant_graph.png) -### Default Attack discovery graph - -![DefaultAttackDiscoveryGraph](./docs/img/default_attack_discovery_graph.png) - ## Development ### Generate graph structure To generate the graph structure, run `yarn draw-graph` from the plugin directory. -The graphs will be generated in the `docs/img` directory of the plugin. +The graph will be generated in the `docs/img` directory of the plugin. ### Testing -To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. +To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. \ No newline at end of file diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png index 159b69c6d95723f08843347b2bb14d75ec2f3905..e4ef8382317e5f827778d1bb34984644f87bbf9d 100644 GIT binary patch literal 30104 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 S?F<&7j~_pA#Dr2mr~U_C`zs#+ literal 29798 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 diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png deleted file mode 100644 index 658490900cca60fc511e729fc08e8ea5e411d60f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22551 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 diff --git a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts index 3b65d307ce385..c44912ebf8d94 100644 --- a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts +++ b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts @@ -5,13 +5,11 @@ * 2.0. */ -import type { ElasticsearchClient } from '@kbn/core/server'; import { ToolingLog } from '@kbn/tooling-log'; import fs from 'fs/promises'; import path from 'path'; import { ActionsClientChatOpenAI, - type ActionsClientLlm, ActionsClientSimpleChatModel, } from '@kbn/langchain/server/language_models'; import type { Logger } from '@kbn/logging'; @@ -19,11 +17,6 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; import { FakeLLM } from '@langchain/core/utils/testing'; import { createOpenAIFunctionsAgent } from 'langchain/agents'; import { getDefaultAssistantGraph } from '../server/lib/langchain/graphs/default_assistant_graph/graph'; -import { getDefaultAttackDiscoveryGraph } from '../server/lib/attack_discovery/graphs/default_attack_discovery_graph'; - -interface Drawable { - drawMermaidPng: () => Promise; -} // Just defining some test variables to get the graph to compile.. const testPrompt = ChatPromptTemplate.fromMessages([ @@ -41,7 +34,7 @@ const createLlmInstance = () => { return mockLlm; }; -async function getAssistantGraph(logger: Logger): Promise { +async function getGraph(logger: Logger) { const agentRunnable = await createOpenAIFunctionsAgent({ llm: mockLlm, tools: [], @@ -58,49 +51,16 @@ async function getAssistantGraph(logger: Logger): Promise { return graph.getGraph(); } -async function getAttackDiscoveryGraph(logger: Logger): Promise { - const mockEsClient = {} as unknown as ElasticsearchClient; - - const graph = getDefaultAttackDiscoveryGraph({ - anonymizationFields: [], - esClient: mockEsClient, - llm: mockLlm as unknown as ActionsClientLlm, - logger, - replacements: {}, - size: 20, - }); - - return graph.getGraph(); -} - -export const drawGraph = async ({ - getGraph, - outputFilename, -}: { - getGraph: (logger: Logger) => Promise; - outputFilename: string; -}) => { +export const draw = async () => { const logger = new ToolingLog({ level: 'info', writeTo: process.stdout, }) as unknown as Logger; logger.info('Compiling graph'); - const outputPath = path.join(__dirname, outputFilename); + const outputPath = path.join(__dirname, '../docs/img/default_assistant_graph.png'); const graph = await getGraph(logger); const output = await graph.drawMermaidPng(); const buffer = Buffer.from(await output.arrayBuffer()); logger.info(`Writing graph to ${outputPath}`); await fs.writeFile(outputPath, buffer); }; - -export const draw = async () => { - await drawGraph({ - getGraph: getAssistantGraph, - outputFilename: '../docs/img/default_assistant_graph.png', - }); - - await drawGraph({ - getGraph: getAttackDiscoveryGraph, - outputFilename: '../docs/img/default_attack_discovery_graph.png', - }); -}; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts index ee54e9c451ea2..9e8a0b5d2ac90 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts @@ -6,7 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; -import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; +import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; export const getAttackDiscoverySearchEsMock = () => { const searchResponse: estypes.SearchResponse = { diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts index 473965a835f14..7e20e292a9868 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts @@ -8,7 +8,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; -import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; type ConversationsDataClientContract = PublicMethodsOf; export type ConversationsDataClientMock = jest.Mocked; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index d53ceaa586975..b52e7db536a3d 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -26,7 +26,7 @@ import { GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; -import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts index ae736c77c30ef..def0a81acea37 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts @@ -16,7 +16,7 @@ import { getPromptsSearchEsMock } from './prompts_schema.mock'; import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types'; import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock'; import { getAttackDiscoverySearchEsMock } from './attack_discovery_schema.mock'; -import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; +import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; export const responseMock = { create: httpServerMock.createResponseFactory, diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts similarity index 94% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts index a82ec24c7041e..6e9cc39597bd7 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts @@ -10,11 +10,11 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { createAttackDiscovery } from './create_attack_discovery'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; +import { getAttackDiscovery } from './get_attack_discovery'; import { loggerMock } from '@kbn/logging-mocks'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); -jest.mock('../get_attack_discovery/get_attack_discovery'); +jest.mock('./get_attack_discovery'); const attackDiscoveryCreate: AttackDiscoveryCreateProps = { attackDiscoveries: [], apiConfig: { diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts index fc511dc559d30..7304ab3488529 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts @@ -9,8 +9,8 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; -import { CreateAttackDiscoverySchema } from '../types'; +import { getAttackDiscovery } from './get_attack_discovery'; +import { CreateAttackDiscoverySchema } from './types'; export interface CreateAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts similarity index 100% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts index 945603b517938..e80d1e4589838 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts @@ -8,8 +8,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { EsAttackDiscoverySchema } from '../types'; -import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; +import { EsAttackDiscoverySchema } from './types'; +import { transformESSearchToAttackDiscovery } from './transforms'; const MAX_ITEMS = 10000; export interface FindAllAttackDiscoveriesParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts index 53d74e6e92f42..10688ce25b25e 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts @@ -9,7 +9,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts index 07fde44080026..532c35ac89c05 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from '../types'; -import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; +import { EsAttackDiscoverySchema } from './types'; +import { transformESSearchToAttackDiscovery } from './transforms'; export interface FindAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts index af1a1827cbddd..4ee89fb7a3bc0 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts @@ -8,7 +8,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { getAttackDiscovery } from './get_attack_discovery'; -import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; import { AuthenticatedUser } from '@kbn/core-security-common'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts index ae2051d9e480b..d0cf6fd19ae05 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from '../types'; -import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; +import { EsAttackDiscoverySchema } from './types'; +import { transformESSearchToAttackDiscovery } from './transforms'; export interface GetAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts index 5aac100f5f52c..ca053743c8035 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts @@ -11,15 +11,12 @@ import { AttackDiscoveryResponse, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { findAllAttackDiscoveries } from './find_all_attack_discoveries/find_all_attack_discoveries'; -import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id'; -import { updateAttackDiscovery } from './update_attack_discovery/update_attack_discovery'; -import { createAttackDiscovery } from './create_attack_discovery/create_attack_discovery'; -import { getAttackDiscovery } from './get_attack_discovery/get_attack_discovery'; -import { - AIAssistantDataClient, - AIAssistantDataClientParams, -} from '../../../ai_assistant_data_clients'; +import { findAllAttackDiscoveries } from './find_all_attack_discoveries'; +import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; +import { updateAttackDiscovery } from './update_attack_discovery'; +import { createAttackDiscovery } from './create_attack_discovery'; +import { getAttackDiscovery } from './get_attack_discovery'; +import { AIAssistantDataClient, AIAssistantDataClientParams } from '..'; type AttackDiscoveryDataClientParams = AIAssistantDataClientParams; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts similarity index 98% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts index 765d40f7a3226..d9a37582f48b0 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts @@ -7,7 +7,7 @@ import { estypes } from '@elastic/elasticsearch'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from '../types'; +import { EsAttackDiscoverySchema } from './types'; export const transformESSearchToAttackDiscovery = ( response: estypes.SearchResponse diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts index 08be262fede5a..4a17c50e06af4 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts @@ -6,7 +6,7 @@ */ import { AttackDiscoveryStatus, Provider } from '@kbn/elastic-assistant-common'; -import { EsReplacementSchema } from '../../../ai_assistant_data_clients/conversations/types'; +import { EsReplacementSchema } from '../conversations/types'; export interface EsAttackDiscoverySchema { '@timestamp': string; @@ -53,7 +53,7 @@ export interface CreateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown?: string; + entity_summary_markdown: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts similarity index 97% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts index 8d98839c092aa..24deda445f320 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts @@ -7,7 +7,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; +import { getAttackDiscovery } from './get_attack_discovery'; import { updateAttackDiscovery } from './update_attack_discovery'; import { AttackDiscoveryResponse, @@ -15,7 +15,7 @@ import { AttackDiscoveryUpdateProps, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -jest.mock('../get_attack_discovery/get_attack_discovery'); +jest.mock('./get_attack_discovery'); const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); const user = { diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts index c810a71c5f1a3..73a386bbb4362 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts @@ -14,8 +14,8 @@ import { UUID, } from '@kbn/elastic-assistant-common'; import * as uuid from 'uuid'; -import { EsReplacementSchema } from '../../../../ai_assistant_data_clients/conversations/types'; -import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; +import { EsReplacementSchema } from '../conversations/types'; +import { getAttackDiscovery } from './get_attack_discovery'; export interface UpdateAttackDiscoverySchema { id: UUID; @@ -25,7 +25,7 @@ export interface UpdateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown?: string; + entity_summary_markdown: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index 4cde64424ed7e..08912f41a8bbc 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -11,7 +11,7 @@ import type { AuthenticatedUser, Logger, ElasticsearchClient } from '@kbn/core/s import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { Subject } from 'rxjs'; -import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration'; +import { attackDiscoveryFieldMap } from '../ai_assistant_data_clients/attack_discovery/field_maps_configuration'; import { getDefaultAnonymizationFields } from '../../common/anonymization'; import { AssistantResourceNames, GetElser } from '../types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; @@ -34,7 +34,7 @@ import { AIAssistantKnowledgeBaseDataClient, GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; const TOTAL_FIELDS_LIMIT = 2500; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts deleted file mode 100644 index d149b8c4cd44d..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Example } from 'langsmith/schemas'; - -export const exampleWithReplacements: Example = { - id: '5D436078-B2CF-487A-A0FA-7CB46696F54E', - created_at: '2024-10-10T23:01:19.350232+00:00', - dataset_id: '0DA3497B-B084-4105-AFC0-2D8E05DE4B7C', - modified_at: '2024-10-10T23:01:19.350232+00:00', - inputs: {}, - outputs: { - attackDiscoveries: [ - { - title: 'Critical Malware and Phishing Alerts on host e1cb3cf0-30f3-4f99-a9c8-518b955c6f90', - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - timestamp: '2024-10-10T22:59:52.749Z', - detailsMarkdown: - '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', - summaryMarkdown: - 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', - mitreAttackTactics: ['Credential Access', 'Input Capture'], - entitySummaryMarkdown: - 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.', - }, - ], - replacements: { - '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', - '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', - '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', - '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', - '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', - '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', - '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', - '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', - 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', - 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', - 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', - }, - }, - runs: [], -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts deleted file mode 100644 index 23c9c08ff5080..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Run } from 'langsmith/schemas'; - -export const runWithReplacements: Run = { - id: 'B7B03FEE-9AC4-4823-AEDB-F8EC20EAD5C4', - inputs: {}, - name: 'test', - outputs: { - attackDiscoveries: [ - { - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - detailsMarkdown: - '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` by the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', - entitySummaryMarkdown: - 'The host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` and user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}` were involved in the attack.', - mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], - summaryMarkdown: - 'A series of critical malware alerts were detected on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` involving the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', - title: 'Critical Malware Attack on macOS Host', - timestamp: '2024-10-11T17:55:59.702Z', - }, - ], - replacements: { - '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', - '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', - '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', - '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', - '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', - '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', - '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', - '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', - 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', - 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', - 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', - }, - }, - run_type: 'evaluation', -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts deleted file mode 100644 index c6f6f09f1d9ae..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts +++ /dev/null @@ -1,911 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; - -export const DEFAULT_EVAL_ANONYMIZATION_FIELDS: AnonymizationFieldResponse[] = [ - { - id: 'Mx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: '_id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'NB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: '@timestamp', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'NR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'cloud.availability_zone', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Nh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'cloud.provider', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Nx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'cloud.region', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'OB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'destination.ip', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'OR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'dns.question.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Oh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'dns.question.type', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Ox09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'event.category', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'PB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'event.dataset', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'PR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'event.module', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Ph09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'event.outcome', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Px09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'file.Ext.original.path', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'QB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'file.hash.sha256', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'QR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'file.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Qh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'file.path', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Qx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'group.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'RB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'group.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'RR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.asset.criticality', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Rh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.name', - allowed: true, - anonymized: true, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Rx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.os.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'SB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.os.version', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'SR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.risk.calculated_level', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Sh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'host.risk.calculated_score_norm', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Sx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.original_time', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'TB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.risk_score', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'TR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.description', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Th09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Tx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.references', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'UB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.framework', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'UR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.tactic.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Uh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.tactic.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Ux09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.tactic.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'VB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'VR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Vh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Vx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.subtechnique.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'WB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.subtechnique.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'WR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.rule.threat.technique.subtechnique.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Wh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.severity', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Wx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'kibana.alert.workflow_status', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'XB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'message', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'XR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'network.protocol', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Xh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.args', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Xx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.code_signature.exists', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'YB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.code_signature.signing_id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'YR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.code_signature.status', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Yh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.code_signature.subject_name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Yx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.code_signature.trusted', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ZB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.command_line', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ZR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.executable', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Zh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.exit_code', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'Zx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.Ext.memory_region.bytes_compressed_present', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'aB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.Ext.memory_region.malware_signature.all_names', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'aR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.Ext.memory_region.malware_signature.primary.matches', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ah09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.Ext.memory_region.malware_signature.primary.signature.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ax09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.Ext.token.integrity_level_name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'bB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.hash.md5', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'bR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.hash.sha1', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'bh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.hash.sha256', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'bx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'cB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.args', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'cR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.args_count', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ch09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.code_signature.exists', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'cx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.code_signature.status', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'dB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.code_signature.subject_name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'dR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.code_signature.trusted', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'dh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.command_line', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'dx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.executable', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'eB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.parent.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'eR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.pe.original_file_name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'eh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.pid', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ex09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'process.working_directory', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'fB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.feature', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'fR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.data', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'fh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.entropy', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'fx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.extension', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'gB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.metrics', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'gR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.operation', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'gh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.path', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'gx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.files.score', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'hB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'Ransomware.version', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'hR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'rule.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'hh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'rule.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'hx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'source.ip', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'iB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.framework', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'iR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.tactic.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ih09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.tactic.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'ix09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.tactic.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'jB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'jR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'jh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'jx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.subtechnique.id', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'kB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.subtechnique.name', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'kR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'threat.technique.subtechnique.reference', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'kh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'user.asset.criticality', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'kx09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'user.domain', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'lB09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'user.name', - allowed: true, - anonymized: true, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'lR09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'user.risk.calculated_level', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, - { - id: 'lh09VpEBOiz7eA-eF2fb', - timestamp: '2024-08-15T13:32:10.073Z', - field: 'user.risk.calculated_score_norm', - allowed: true, - anonymized: false, - createdAt: '2024-08-15T13:32:10.073Z', - namespace: 'default', - }, -]; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts deleted file mode 100644 index 93d442bad5e9b..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ExampleInput, ExampleInputWithOverrides } from '.'; - -const validInput = { - attackDiscoveries: null, - attackDiscoveryPrompt: 'prompt', - anonymizedAlerts: [{ pageContent: 'content', metadata: { key: 'value' } }], - combinedGenerations: 'gen1gen2', - combinedRefinements: 'ref1ref2', - errors: ['error1', 'error2'], - generationAttempts: 1, - generations: ['gen1', 'gen2'], - hallucinationFailures: 0, - maxGenerationAttempts: 5, - maxHallucinationFailures: 2, - maxRepeatedGenerations: 3, - refinements: ['ref1', 'ref2'], - refinePrompt: 'refine prompt', - replacements: { key: 'replacement' }, - unrefinedResults: null, -}; - -describe('ExampleInput Schema', () => { - it('validates a correct ExampleInput object', () => { - expect(() => ExampleInput.parse(validInput)).not.toThrow(); - }); - - it('throws given an invalid ExampleInput', () => { - const invalidInput = { - attackDiscoveries: 'invalid', // should be an array or null - }; - - expect(() => ExampleInput.parse(invalidInput)).toThrow(); - }); - - it('removes unknown properties', () => { - const hasUnknownProperties = { - ...validInput, - unknownProperty: 'unknown', // <-- should be removed - }; - - const parsed = ExampleInput.parse(hasUnknownProperties); - - expect(parsed).not.toHaveProperty('unknownProperty'); - }); -}); - -describe('ExampleInputWithOverrides Schema', () => { - it('validates a correct ExampleInputWithOverrides object', () => { - const validInputWithOverrides = { - ...validInput, - overrides: { - attackDiscoveryPrompt: 'ad prompt override', - refinePrompt: 'refine prompt override', - }, - }; - - expect(() => ExampleInputWithOverrides.parse(validInputWithOverrides)).not.toThrow(); - }); - - it('throws when given an invalid ExampleInputWithOverrides object', () => { - const invalidInputWithOverrides = { - attackDiscoveries: null, - overrides: 'invalid', // should be an object - }; - - expect(() => ExampleInputWithOverrides.parse(invalidInputWithOverrides)).toThrow(); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts deleted file mode 100644 index 8183695fd7d2f..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import { z } from '@kbn/zod'; - -const Document = z.object({ - pageContent: z.string(), - metadata: z.record(z.string(), z.any()), -}); - -type Document = z.infer; - -/** - * Parses the input from an example in a LangSmith dataset - */ -export const ExampleInput = z.object({ - attackDiscoveries: z.array(AttackDiscovery).nullable().optional(), - attackDiscoveryPrompt: z.string().optional(), - anonymizedAlerts: z.array(Document).optional(), - combinedGenerations: z.string().optional(), - combinedRefinements: z.string().optional(), - errors: z.array(z.string()).optional(), - generationAttempts: z.number().optional(), - generations: z.array(z.string()).optional(), - hallucinationFailures: z.number().optional(), - maxGenerationAttempts: z.number().optional(), - maxHallucinationFailures: z.number().optional(), - maxRepeatedGenerations: z.number().optional(), - refinements: z.array(z.string()).optional(), - refinePrompt: z.string().optional(), - replacements: Replacements.optional(), - unrefinedResults: z.array(AttackDiscovery).nullable().optional(), -}); - -export type ExampleInput = z.infer; - -/** - * The optional overrides for an example input - */ -export const ExampleInputWithOverrides = z.intersection( - ExampleInput, - z.object({ - overrides: ExampleInput.optional(), - }) -); - -export type ExampleInputWithOverrides = z.infer; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts deleted file mode 100644 index 8ea30103c0768..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getDefaultPromptTemplate } from '.'; - -describe('getDefaultPromptTemplate', () => { - it('returns the expected prompt template', () => { - const expectedTemplate = `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": - -[BEGIN rubric] -1. Is the submission non-empty and not null? -2. Is the submission well-formed JSON? -3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? -4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? -5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? -6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? -7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? -[END rubric] - -[BEGIN DATA] -{input} -[BEGIN submission] -{output} -[END submission] -[BEGIN expected response] -{reference} -[END expected response] -[END DATA] - -{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. - -Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; - - const result = getDefaultPromptTemplate(); - - expect(result).toBe(expectedTemplate); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts deleted file mode 100644 index 08e10f00e7f77..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getDefaultPromptTemplate = - () => `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": - -[BEGIN rubric] -1. Is the submission non-empty and not null? -2. Is the submission well-formed JSON? -3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? -4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? -5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? -6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? -7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? -[END rubric] - -[BEGIN DATA] -{input} -[BEGIN submission] -{output} -[END submission] -[BEGIN expected response] -{reference} -[END expected response] -[END DATA] - -{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. - -Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts deleted file mode 100644 index c261f151b99ab..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { omit } from 'lodash/fp'; - -import { getExampleAttackDiscoveriesWithReplacements } from '.'; -import { exampleWithReplacements } from '../../../__mocks__/mock_examples'; - -describe('getExampleAttackDiscoveriesWithReplacements', () => { - it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { - const result = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); - - expect(result).toEqual([ - { - title: 'Critical Malware and Phishing Alerts on host SRVMAC08', - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - timestamp: '2024-10-10T22:59:52.749Z', - detailsMarkdown: - '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', - summaryMarkdown: - 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', - mitreAttackTactics: ['Credential Access', 'Input Capture'], - entitySummaryMarkdown: - 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}.', - }, - ]); - }); - - it('returns an empty entitySummaryMarkdown when the entitySummaryMarkdown is missing', () => { - const missingEntitySummaryMarkdown = omit( - 'entitySummaryMarkdown', - exampleWithReplacements.outputs?.attackDiscoveries?.[0] - ); - - const exampleWithMissingEntitySummaryMarkdown = { - ...exampleWithReplacements, - outputs: { - ...exampleWithReplacements.outputs, - attackDiscoveries: [missingEntitySummaryMarkdown], - }, - }; - - const result = getExampleAttackDiscoveriesWithReplacements( - exampleWithMissingEntitySummaryMarkdown - ); - - expect(result).toEqual([ - { - title: 'Critical Malware and Phishing Alerts on host SRVMAC08', - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - timestamp: '2024-10-10T22:59:52.749Z', - detailsMarkdown: - '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', - summaryMarkdown: - 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', - mitreAttackTactics: ['Credential Access', 'Input Capture'], - entitySummaryMarkdown: '', - }, - ]); - }); - - it('throws when an example is undefined', () => { - expect(() => getExampleAttackDiscoveriesWithReplacements(undefined)).toThrowError(); - }); - - it('throws when the example is missing attackDiscoveries', () => { - const missingAttackDiscoveries = { - ...exampleWithReplacements, - outputs: { - replacements: { ...exampleWithReplacements.outputs?.replacements }, - }, - }; - - expect(() => - getExampleAttackDiscoveriesWithReplacements(missingAttackDiscoveries) - ).toThrowError(); - }); - - it('throws when attackDiscoveries is null', () => { - const nullAttackDiscoveries = { - ...exampleWithReplacements, - outputs: { - attackDiscoveries: null, - replacements: { ...exampleWithReplacements.outputs?.replacements }, - }, - }; - - expect(() => getExampleAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); - }); - - it('returns the original attack discoveries when replacements are missing', () => { - const missingReplacements = { - ...exampleWithReplacements, - outputs: { - attackDiscoveries: [...exampleWithReplacements.outputs?.attackDiscoveries], - }, - }; - - const result = getExampleAttackDiscoveriesWithReplacements(missingReplacements); - - expect(result).toEqual(exampleWithReplacements.outputs?.attackDiscoveries); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts deleted file mode 100644 index 8fc5de2a08ed1..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; -import type { Example } from 'langsmith/schemas'; - -import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; - -export const getExampleAttackDiscoveriesWithReplacements = ( - example: Example | undefined -): AttackDiscoveries => { - const exampleAttackDiscoveries = example?.outputs?.attackDiscoveries; - const exampleReplacements = example?.outputs?.replacements ?? {}; - - // NOTE: calls to `parse` throw an error if the Example input is invalid - const validatedAttackDiscoveries = AttackDiscoveries.parse(exampleAttackDiscoveries); - const validatedReplacements = Replacements.parse(exampleReplacements); - - const withReplacements = getDiscoveriesWithOriginalValues({ - attackDiscoveries: validatedAttackDiscoveries, - replacements: validatedReplacements, - }); - - return withReplacements; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts deleted file mode 100644 index bd22e5d952b07..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { omit } from 'lodash/fp'; - -import { getRunAttackDiscoveriesWithReplacements } from '.'; -import { runWithReplacements } from '../../../__mocks__/mock_runs'; - -describe('getRunAttackDiscoveriesWithReplacements', () => { - it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { - const result = getRunAttackDiscoveriesWithReplacements(runWithReplacements); - - expect(result).toEqual([ - { - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - detailsMarkdown: - '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', - entitySummaryMarkdown: - 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', - mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], - summaryMarkdown: - 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', - title: 'Critical Malware Attack on macOS Host', - timestamp: '2024-10-11T17:55:59.702Z', - }, - ]); - }); - - it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { - const missingEntitySummaryMarkdown = omit( - 'entitySummaryMarkdown', - runWithReplacements.outputs?.attackDiscoveries?.[0] - ); - - const runWithMissingEntitySummaryMarkdown = { - ...runWithReplacements, - outputs: { - ...runWithReplacements.outputs, - attackDiscoveries: [missingEntitySummaryMarkdown], - }, - }; - - const result = getRunAttackDiscoveriesWithReplacements(runWithMissingEntitySummaryMarkdown); - - expect(result).toEqual([ - { - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - detailsMarkdown: - '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', - entitySummaryMarkdown: '', - mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], - summaryMarkdown: - 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', - title: 'Critical Malware Attack on macOS Host', - timestamp: '2024-10-11T17:55:59.702Z', - }, - ]); - }); - - it('throws when the run is missing attackDiscoveries', () => { - const missingAttackDiscoveries = { - ...runWithReplacements, - outputs: { - replacements: { ...runWithReplacements.outputs?.replacements }, - }, - }; - - expect(() => getRunAttackDiscoveriesWithReplacements(missingAttackDiscoveries)).toThrowError(); - }); - - it('throws when attackDiscoveries is null', () => { - const nullAttackDiscoveries = { - ...runWithReplacements, - outputs: { - attackDiscoveries: null, - replacements: { ...runWithReplacements.outputs?.replacements }, - }, - }; - - expect(() => getRunAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); - }); - - it('returns the original attack discoveries when replacements are missing', () => { - const missingReplacements = { - ...runWithReplacements, - outputs: { - attackDiscoveries: [...runWithReplacements.outputs?.attackDiscoveries], - }, - }; - - const result = getRunAttackDiscoveriesWithReplacements(missingReplacements); - - expect(result).toEqual(runWithReplacements.outputs?.attackDiscoveries); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts deleted file mode 100644 index 01193320f712b..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; -import type { Run } from 'langsmith/schemas'; - -import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; - -export const getRunAttackDiscoveriesWithReplacements = (run: Run): AttackDiscoveries => { - const runAttackDiscoveries = run.outputs?.attackDiscoveries; - const runReplacements = run.outputs?.replacements ?? {}; - - // NOTE: calls to `parse` throw an error if the Run Input is invalid - const validatedAttackDiscoveries = AttackDiscoveries.parse(runAttackDiscoveries); - const validatedReplacements = Replacements.parse(runReplacements); - - const withReplacements = getDiscoveriesWithOriginalValues({ - attackDiscoveries: validatedAttackDiscoveries, - replacements: validatedReplacements, - }); - - return withReplacements; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts deleted file mode 100644 index 829e27df73f14..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PromptTemplate } from '@langchain/core/prompts'; -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import { loadEvaluator } from 'langchain/evaluation'; - -import { type GetCustomEvaluatorOptions, getCustomEvaluator } from '.'; -import { getDefaultPromptTemplate } from './get_default_prompt_template'; -import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; -import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; -import { exampleWithReplacements } from '../../__mocks__/mock_examples'; -import { runWithReplacements } from '../../__mocks__/mock_runs'; - -const mockLlm = jest.fn() as unknown as ActionsClientLlm; - -jest.mock('langchain/evaluation', () => ({ - ...jest.requireActual('langchain/evaluation'), - loadEvaluator: jest.fn().mockResolvedValue({ - evaluateStrings: jest.fn().mockResolvedValue({ - key: 'correctness', - score: 0.9, - }), - }), -})); - -const options: GetCustomEvaluatorOptions = { - criteria: 'correctness', - key: 'attack_discovery_correctness', - llm: mockLlm, - template: getDefaultPromptTemplate(), -}; - -describe('getCustomEvaluator', () => { - beforeEach(() => jest.clearAllMocks()); - - it('returns an evaluator function', () => { - const evaluator = getCustomEvaluator(options); - - expect(typeof evaluator).toBe('function'); - }); - - it('calls loadEvaluator with the expected arguments', async () => { - const evaluator = getCustomEvaluator(options); - - await evaluator(runWithReplacements, exampleWithReplacements); - - expect(loadEvaluator).toHaveBeenCalledWith('labeled_criteria', { - criteria: options.criteria, - chainOptions: { - prompt: PromptTemplate.fromTemplate(options.template), - }, - llm: mockLlm, - }); - }); - - it('calls evaluateStrings with the expected arguments', async () => { - const mockEvaluateStrings = jest.fn().mockResolvedValue({ - key: 'correctness', - score: 0.9, - }); - - (loadEvaluator as jest.Mock).mockResolvedValue({ - evaluateStrings: mockEvaluateStrings, - }); - - const evaluator = getCustomEvaluator(options); - - await evaluator(runWithReplacements, exampleWithReplacements); - - const prediction = getRunAttackDiscoveriesWithReplacements(runWithReplacements); - const reference = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); - - expect(mockEvaluateStrings).toHaveBeenCalledWith({ - input: '', - prediction: JSON.stringify(prediction, null, 2), - reference: JSON.stringify(reference, null, 2), - }); - }); - - it('returns the expected result', async () => { - const evaluator = getCustomEvaluator(options); - - const result = await evaluator(runWithReplacements, exampleWithReplacements); - - expect(result).toEqual({ key: 'attack_discovery_correctness', score: 0.9 }); - }); - - it('throws given an undefined example', async () => { - const evaluator = getCustomEvaluator(options); - - await expect(async () => evaluator(runWithReplacements, undefined)).rejects.toThrow(); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts deleted file mode 100644 index bcabe410c1b72..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import { PromptTemplate } from '@langchain/core/prompts'; -import type { EvaluationResult } from 'langsmith/evaluation'; -import type { Run, Example } from 'langsmith/schemas'; -import { CriteriaLike, loadEvaluator } from 'langchain/evaluation'; - -import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; -import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; - -export interface GetCustomEvaluatorOptions { - /** - * Examples: - * - "conciseness" - * - "relevance" - * - "correctness" - * - "detail" - */ - criteria: CriteriaLike; - /** - * The evaluation score will use this key - */ - key: string; - /** - * LLm to use for evaluation - */ - llm: ActionsClientLlm; - /** - * A prompt template that uses the {input}, {submission}, and {reference} variables - */ - template: string; -} - -export type CustomEvaluator = ( - rootRun: Run, - example: Example | undefined -) => Promise; - -export const getCustomEvaluator = - ({ criteria, key, llm, template }: GetCustomEvaluatorOptions): CustomEvaluator => - async (rootRun, example) => { - const chain = await loadEvaluator('labeled_criteria', { - criteria, - chainOptions: { - prompt: PromptTemplate.fromTemplate(template), - }, - llm, - }); - - const exampleAttackDiscoveriesWithReplacements = - getExampleAttackDiscoveriesWithReplacements(example); - - const runAttackDiscoveriesWithReplacements = getRunAttackDiscoveriesWithReplacements(rootRun); - - // NOTE: res contains a score, as well as the reasoning for the score - const res = await chain.evaluateStrings({ - input: '', // empty for now, but this could be the alerts, i.e. JSON.stringify(rootRun.outputs?.anonymizedAlerts, null, 2), - prediction: JSON.stringify(runAttackDiscoveriesWithReplacements, null, 2), - reference: JSON.stringify(exampleAttackDiscoveriesWithReplacements, null, 2), - }); - - return { key, score: res.score }; - }; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts deleted file mode 100644 index 423248aa5c3d6..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscovery } from '@kbn/elastic-assistant-common'; -import { omit } from 'lodash/fp'; - -import { getDiscoveriesWithOriginalValues } from '.'; -import { runWithReplacements } from '../../__mocks__/mock_runs'; - -describe('getDiscoveriesWithOriginalValues', () => { - it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { - const result = getDiscoveriesWithOriginalValues({ - attackDiscoveries: runWithReplacements.outputs?.attackDiscoveries, - replacements: runWithReplacements.outputs?.replacements, - }); - - expect(result).toEqual([ - { - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - detailsMarkdown: - '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', - entitySummaryMarkdown: - 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', - mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], - summaryMarkdown: - 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', - title: 'Critical Malware Attack on macOS Host', - timestamp: '2024-10-11T17:55:59.702Z', - }, - ]); - }); - - it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { - const missingEntitySummaryMarkdown = omit( - 'entitySummaryMarkdown', - runWithReplacements.outputs?.attackDiscoveries?.[0] - ) as unknown as AttackDiscovery; - - const result = getDiscoveriesWithOriginalValues({ - attackDiscoveries: [missingEntitySummaryMarkdown], - replacements: runWithReplacements.outputs?.replacements, - }); - expect(result).toEqual([ - { - alertIds: [ - '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', - 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', - '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', - '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', - 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', - '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', - '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', - '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', - ], - detailsMarkdown: - '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', - entitySummaryMarkdown: '', - mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], - summaryMarkdown: - 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', - title: 'Critical Malware Attack on macOS Host', - timestamp: '2024-10-11T17:55:59.702Z', - }, - ]); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts deleted file mode 100644 index 1ef88e2208d1f..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - type AttackDiscovery, - Replacements, - replaceAnonymizedValuesWithOriginalValues, -} from '@kbn/elastic-assistant-common'; - -export const getDiscoveriesWithOriginalValues = ({ - attackDiscoveries, - replacements, -}: { - attackDiscoveries: AttackDiscovery[]; - replacements: Replacements; -}): AttackDiscovery[] => - attackDiscoveries.map((attackDiscovery) => ({ - ...attackDiscovery, - detailsMarkdown: replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.detailsMarkdown, - replacements, - }), - entitySummaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.entitySummaryMarkdown ?? '', - replacements, - }), - summaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.summaryMarkdown, - replacements, - }), - title: replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.title, - replacements, - }), - })); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts deleted file mode 100644 index 132a819d44ec8..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { loggerMock } from '@kbn/logging-mocks'; - -import { getEvaluatorLlm } from '.'; - -jest.mock('@kbn/langchain/server', () => ({ - ...jest.requireActual('@kbn/langchain/server'), - - ActionsClientLlm: jest.fn(), -})); - -const connectorTimeout = 1000; - -const evaluatorConnectorId = 'evaluator-connector-id'; -const evaluatorConnector = { - id: 'evaluatorConnectorId', - actionTypeId: '.gen-ai', - name: 'GPT-4o', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, -} as Connector; - -const experimentConnector: Connector = { - name: 'Gemini 1.5 Pro 002', - actionTypeId: '.gemini', - config: { - apiUrl: 'https://example.com', - defaultModel: 'gemini-1.5-pro-002', - gcpRegion: 'test-region', - gcpProjectID: 'test-project-id', - }, - secrets: { - credentialsJson: '{}', - }, - id: 'gemini-1-5-pro-002', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, -} as Connector; - -const logger = loggerMock.create(); - -describe('getEvaluatorLlm', () => { - beforeEach(() => jest.clearAllMocks()); - - describe('getting the evaluation connector', () => { - it("calls actionsClient.get with the evaluator connector ID when it's provided", async () => { - const actionsClient = { - get: jest.fn(), - } as unknown as ActionsClient; - - await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector, - langSmithApiKey: undefined, - logger, - }); - - expect(actionsClient.get).toHaveBeenCalledWith({ - id: evaluatorConnectorId, - throwIfSystemAction: false, - }); - }); - - it("calls actionsClient.get with the experiment connector ID when the evaluator connector ID isn't provided", async () => { - const actionsClient = { - get: jest.fn().mockResolvedValue(null), - } as unknown as ActionsClient; - - await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId: undefined, - experimentConnector, - langSmithApiKey: undefined, - logger, - }); - - expect(actionsClient.get).toHaveBeenCalledWith({ - id: experimentConnector.id, - throwIfSystemAction: false, - }); - }); - - it('falls back to the experiment connector when the evaluator connector is not found', async () => { - const actionsClient = { - get: jest.fn().mockResolvedValue(null), - } as unknown as ActionsClient; - - await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector, - langSmithApiKey: undefined, - logger, - }); - - expect(ActionsClientLlm).toHaveBeenCalledWith( - expect.objectContaining({ - connectorId: experimentConnector.id, - }) - ); - }); - }); - - it('logs the expected connector names and types', async () => { - const actionsClient = { - get: jest.fn().mockResolvedValue(evaluatorConnector), - } as unknown as ActionsClient; - - await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector, - langSmithApiKey: undefined, - logger, - }); - - expect(logger.info).toHaveBeenCalledWith( - `The ${evaluatorConnector.name} (openai) connector will judge output from the ${experimentConnector.name} (gemini) connector` - ); - }); - - it('creates a new ActionsClientLlm instance with the expected traceOptions', async () => { - const actionsClient = { - get: jest.fn().mockResolvedValue(evaluatorConnector), - } as unknown as ActionsClient; - - await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector, - langSmithApiKey: 'test-api-key', - logger, - }); - - expect(ActionsClientLlm).toHaveBeenCalledWith( - expect.objectContaining({ - traceOptions: { - projectName: 'evaluators', - tracers: expect.any(Array), - }, - }) - ); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts deleted file mode 100644 index 236def9670d07..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; -import { Logger } from '@kbn/core/server'; -import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { PublicMethodsOf } from '@kbn/utility-types'; - -import { getLlmType } from '../../../../../routes/utils'; - -export const getEvaluatorLlm = async ({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector, - langSmithApiKey, - logger, -}: { - actionsClient: PublicMethodsOf; - connectorTimeout: number; - evaluatorConnectorId: string | undefined; - experimentConnector: Connector; - langSmithApiKey: string | undefined; - logger: Logger; -}): Promise => { - const evaluatorConnector = - (await actionsClient.get({ - id: evaluatorConnectorId ?? experimentConnector.id, // fallback to the experiment connector if the evaluator connector is not found: - throwIfSystemAction: false, - })) ?? experimentConnector; - - const evaluatorLlmType = getLlmType(evaluatorConnector.actionTypeId); - const experimentLlmType = getLlmType(experimentConnector.actionTypeId); - - logger.info( - `The ${evaluatorConnector.name} (${evaluatorLlmType}) connector will judge output from the ${experimentConnector.name} (${experimentLlmType}) connector` - ); - - const traceOptions = { - projectName: 'evaluators', - tracers: [ - ...getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: 'evaluators', - logger, - }), - ], - }; - - return new ActionsClientLlm({ - actionsClient, - connectorId: evaluatorConnector.id, - llmType: evaluatorLlmType, - logger, - temperature: 0, // zero temperature for evaluation - timeout: connectorTimeout, - traceOptions, - }); -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts deleted file mode 100644 index 47f36bc6fb0e7..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { omit } from 'lodash/fp'; -import type { Example } from 'langsmith/schemas'; - -import { getGraphInputOverrides } from '.'; -import { exampleWithReplacements } from '../../__mocks__/mock_examples'; - -const exampleWithAlerts: Example = { - ...exampleWithReplacements, - outputs: { - ...exampleWithReplacements.outputs, - anonymizedAlerts: [ - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - ], - }, -}; - -const exampleWithNoReplacements: Example = { - ...exampleWithReplacements, - outputs: { - ...omit('replacements', exampleWithReplacements.outputs), - }, -}; - -describe('getGraphInputOverrides', () => { - describe('root-level outputs overrides', () => { - it('returns the anonymizedAlerts from the root level of the outputs when present', () => { - const overrides = getGraphInputOverrides(exampleWithAlerts.outputs); - - expect(overrides.anonymizedAlerts).toEqual(exampleWithAlerts.outputs?.anonymizedAlerts); - }); - - it('does NOT populate the anonymizedAlerts key when it does NOT exist in the outputs', () => { - const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); - - expect(overrides).not.toHaveProperty('anonymizedAlerts'); - }); - - it('returns replacements from the root level of the outputs when present', () => { - const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); - - expect(overrides.replacements).toEqual(exampleWithReplacements.outputs?.replacements); - }); - - it('does NOT populate the replacements key when it does NOT exist in the outputs', () => { - const overrides = getGraphInputOverrides(exampleWithNoReplacements.outputs); - - expect(overrides).not.toHaveProperty('replacements'); - }); - - it('removes unknown properties', () => { - const withUnknownProperties = { - ...exampleWithReplacements, - outputs: { - ...exampleWithReplacements.outputs, - unknownProperty: 'unknown', - }, - }; - - const overrides = getGraphInputOverrides(withUnknownProperties.outputs); - - expect(overrides).not.toHaveProperty('unknownProperty'); - }); - }); - - describe('overrides', () => { - it('returns all overrides at the root level', () => { - const exampleWithOverrides = { - ...exampleWithAlerts, - outputs: { - ...exampleWithAlerts.outputs, - overrides: { - attackDiscoveries: [], - attackDiscoveryPrompt: 'prompt', - anonymizedAlerts: [], - combinedGenerations: 'combinedGenerations', - combinedRefinements: 'combinedRefinements', - errors: ['error'], - generationAttempts: 1, - generations: ['generation'], - hallucinationFailures: 2, - maxGenerationAttempts: 3, - maxHallucinationFailures: 4, - maxRepeatedGenerations: 5, - refinements: ['refinement'], - refinePrompt: 'refinePrompt', - replacements: {}, - unrefinedResults: [], - }, - }, - }; - - const overrides = getGraphInputOverrides(exampleWithOverrides.outputs); - - expect(overrides).toEqual({ - ...exampleWithOverrides.outputs?.overrides, - }); - }); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts deleted file mode 100644 index 232218f4386f8..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { pick } from 'lodash/fp'; - -import { ExampleInputWithOverrides } from '../../example_input'; -import { GraphState } from '../../../graphs/default_attack_discovery_graph/types'; - -/** - * Parses input from an LangSmith dataset example to get the graph input overrides - */ -export const getGraphInputOverrides = (outputs: unknown): Partial => { - const validatedInput = ExampleInputWithOverrides.safeParse(outputs).data ?? {}; // safeParse removes unknown properties - - const { overrides } = validatedInput; - - // return all overrides at the root level: - return { - // pick extracts just the anonymizedAlerts and replacements from the root level of the input, - // and only adds the anonymizedAlerts key if it exists in the input - ...pick('anonymizedAlerts', validatedInput), - ...pick('replacements', validatedInput), - ...overrides, // bring all other overrides to the root level - }; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts deleted file mode 100644 index 40b0f080fe54a..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import { Logger } from '@kbn/core/server'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import { asyncForEach } from '@kbn/std'; -import { PublicMethodsOf } from '@kbn/utility-types'; - -import { DEFAULT_EVAL_ANONYMIZATION_FIELDS } from './constants'; -import { AttackDiscoveryGraphMetadata } from '../../langchain/graphs'; -import { DefaultAttackDiscoveryGraph } from '../graphs/default_attack_discovery_graph'; -import { getLlmType } from '../../../routes/utils'; -import { runEvaluations } from './run_evaluations'; - -export const evaluateAttackDiscovery = async ({ - actionsClient, - attackDiscoveryGraphs, - alertsIndexPattern, - anonymizationFields = DEFAULT_EVAL_ANONYMIZATION_FIELDS, // determines which fields are included in the alerts - connectors, - connectorTimeout, - datasetName, - esClient, - evaluationId, - evaluatorConnectorId, - langSmithApiKey, - langSmithProject, - logger, - runName, - size, -}: { - actionsClient: PublicMethodsOf; - attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; - alertsIndexPattern: string; - anonymizationFields?: AnonymizationFieldResponse[]; - connectors: Connector[]; - connectorTimeout: number; - datasetName: string; - esClient: ElasticsearchClient; - evaluationId: string; - evaluatorConnectorId: string | undefined; - langSmithApiKey: string | undefined; - langSmithProject: string | undefined; - logger: Logger; - runName: string; - size: number; -}): Promise => { - await asyncForEach(attackDiscoveryGraphs, async ({ getDefaultAttackDiscoveryGraph }) => { - // create a graph for every connector: - const graphs: Array<{ - connector: Connector; - graph: DefaultAttackDiscoveryGraph; - llmType: string | undefined; - name: string; - traceOptions: { - projectName: string | undefined; - tracers: LangChainTracer[]; - }; - }> = connectors.map((connector) => { - const llmType = getLlmType(connector.actionTypeId); - - const traceOptions = { - projectName: langSmithProject, - tracers: [ - ...getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: langSmithProject, - logger, - }), - ], - }; - - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: connector.id, - llmType, - logger, - temperature: 0, // zero temperature for attack discovery, because we want structured JSON output - timeout: connectorTimeout, - traceOptions, - }); - - const graph = getDefaultAttackDiscoveryGraph({ - alertsIndexPattern, - anonymizationFields, - esClient, - llm, - logger, - size, - }); - - return { - connector, - graph, - llmType, - name: `${runName} - ${connector.name} - ${evaluationId} - Attack discovery`, - traceOptions, - }; - }); - - // run the evaluations for each graph: - await runEvaluations({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - datasetName, - graphs, - langSmithApiKey, - logger, - }); - }); -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts deleted file mode 100644 index 19eb99d57c84c..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; -import { Logger } from '@kbn/core/server'; -import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; -import { asyncForEach } from '@kbn/std'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { Client } from 'langsmith'; -import { evaluate } from 'langsmith/evaluation'; - -import { getEvaluatorLlm } from '../helpers/get_evaluator_llm'; -import { getCustomEvaluator } from '../helpers/get_custom_evaluator'; -import { getDefaultPromptTemplate } from '../helpers/get_custom_evaluator/get_default_prompt_template'; -import { getGraphInputOverrides } from '../helpers/get_graph_input_overrides'; -import { DefaultAttackDiscoveryGraph } from '../../graphs/default_attack_discovery_graph'; -import { GraphState } from '../../graphs/default_attack_discovery_graph/types'; - -/** - * Runs an evaluation for each graph so they show up separately (resulting in - * each dataset run grouped by connector) - */ -export const runEvaluations = async ({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - datasetName, - graphs, - langSmithApiKey, - logger, -}: { - actionsClient: PublicMethodsOf; - connectorTimeout: number; - evaluatorConnectorId: string | undefined; - datasetName: string; - graphs: Array<{ - connector: Connector; - graph: DefaultAttackDiscoveryGraph; - llmType: string | undefined; - name: string; - traceOptions: { - projectName: string | undefined; - tracers: LangChainTracer[]; - }; - }>; - langSmithApiKey: string | undefined; - logger: Logger; -}): Promise => - asyncForEach(graphs, async ({ connector, graph, llmType, name, traceOptions }) => { - const subject = `connector "${connector.name}" (${llmType}), running experiment "${name}"`; - - try { - logger.info( - () => - `Evaluating ${subject} with dataset "${datasetName}" and evaluator "${evaluatorConnectorId}"` - ); - - const predict = async (input: unknown): Promise => { - logger.debug(() => `Raw example Input for ${subject}":\n ${input}`); - - // The example `Input` may have overrides for the initial state of the graph: - const overrides = getGraphInputOverrides(input); - - return graph.invoke( - { - ...overrides, - }, - { - callbacks: [...(traceOptions.tracers ?? [])], - runName: name, - tags: ['evaluation', llmType ?? ''], - } - ); - }; - - const llm = await getEvaluatorLlm({ - actionsClient, - connectorTimeout, - evaluatorConnectorId, - experimentConnector: connector, - langSmithApiKey, - logger, - }); - - const customEvaluator = getCustomEvaluator({ - criteria: 'correctness', - key: 'attack_discovery_correctness', - llm, - template: getDefaultPromptTemplate(), - }); - - const evalOutput = await evaluate(predict, { - client: new Client({ apiKey: langSmithApiKey }), - data: datasetName ?? '', - evaluators: [customEvaluator], - experimentPrefix: name, - maxConcurrency: 5, // prevents rate limiting - }); - - logger.info(() => `Evaluation complete for ${subject}`); - - logger.debug( - () => `Evaluation output for ${subject}:\n ${JSON.stringify(evalOutput, null, 2)}` - ); - } catch (e) { - logger.error(`Error evaluating ${subject}: ${e}`); - } - }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts deleted file mode 100644 index fb5df8f26d0c2..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// LangGraph metadata -export const ATTACK_DISCOVERY_GRAPH_RUN_NAME = 'Attack discovery'; -export const ATTACK_DISCOVERY_TAG = 'attack-discovery'; - -// Limits -export const DEFAULT_MAX_GENERATION_ATTEMPTS = 10; -export const DEFAULT_MAX_HALLUCINATION_FAILURES = 5; -export const DEFAULT_MAX_REPEATED_GENERATIONS = 3; - -export const NodeType = { - GENERATE_NODE: 'generate', - REFINE_NODE: 'refine', - RETRIEVE_ANONYMIZED_ALERTS_NODE: 'retrieve_anonymized_alerts', -} as const; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts deleted file mode 100644 index 225c4a2b8935c..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getGenerateOrEndDecision } from '.'; - -describe('getGenerateOrEndDecision', () => { - it('returns "end" when hasZeroAlerts is true', () => { - const result = getGenerateOrEndDecision(true); - - expect(result).toEqual('end'); - }); - - it('returns "generate" when hasZeroAlerts is false', () => { - const result = getGenerateOrEndDecision(false); - - expect(result).toEqual('generate'); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts deleted file mode 100644 index b134b2f3a6118..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getGenerateOrEndDecision = (hasZeroAlerts: boolean): 'end' | 'generate' => - hasZeroAlerts ? 'end' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts deleted file mode 100644 index 06dd1529179fa..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { loggerMock } from '@kbn/logging-mocks'; - -import { getGenerateOrEndEdge } from '.'; -import type { GraphState } from '../../types'; - -const logger = loggerMock.create(); - -const graphState: GraphState = { - attackDiscoveries: null, - attackDiscoveryPrompt: 'prompt', - anonymizedAlerts: [ - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - ], - combinedGenerations: 'generations', - combinedRefinements: 'refinements', - errors: [], - generationAttempts: 0, - generations: [], - hallucinationFailures: 0, - maxGenerationAttempts: 10, - maxHallucinationFailures: 5, - maxRepeatedGenerations: 10, - refinements: [], - refinePrompt: 'refinePrompt', - replacements: {}, - unrefinedResults: null, -}; - -describe('getGenerateOrEndEdge', () => { - beforeEach(() => jest.clearAllMocks()); - - it("returns 'end' when there are zero alerts", () => { - const state: GraphState = { - ...graphState, - anonymizedAlerts: [], // <-- zero alerts - }; - - const edge = getGenerateOrEndEdge(logger); - const result = edge(state); - - expect(result).toEqual('end'); - }); - - it("returns 'generate' when there are alerts", () => { - const edge = getGenerateOrEndEdge(logger); - const result = edge(graphState); - - expect(result).toEqual('generate'); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts deleted file mode 100644 index 5bfc4912298eb..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; - -import { getGenerateOrEndDecision } from './helpers/get_generate_or_end_decision'; -import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; -import type { GraphState } from '../../types'; - -export const getGenerateOrEndEdge = (logger?: Logger) => { - const edge = (state: GraphState): 'end' | 'generate' => { - logger?.debug(() => '---GENERATE OR END---'); - const { anonymizedAlerts } = state; - - const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); - - const decision = getGenerateOrEndDecision(hasZeroAlerts); - - logger?.debug( - () => `generatOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( - { - anonymizedAlerts: anonymizedAlerts.length, - hasZeroAlerts, - }, - null, - 2 - )} -\n---GENERATE OR END: ${decision}---` - ); - return decision; - }; - - return edge; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts deleted file mode 100644 index 42c63b18459ed..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getGenerateOrRefineOrEndDecision } from '.'; - -describe('getGenerateOrRefineOrEndDecision', () => { - it("returns 'end' if getShouldEnd returns true", () => { - const result = getGenerateOrRefineOrEndDecision({ - hasUnrefinedResults: false, - hasZeroAlerts: true, - maxHallucinationFailuresReached: true, - maxRetriesReached: true, - }); - - expect(result).toEqual('end'); - }); - - it("returns 'refine' if hasUnrefinedResults is true and getShouldEnd returns false", () => { - const result = getGenerateOrRefineOrEndDecision({ - hasUnrefinedResults: true, - hasZeroAlerts: false, - maxHallucinationFailuresReached: false, - maxRetriesReached: false, - }); - - expect(result).toEqual('refine'); - }); - - it("returns 'generate' if hasUnrefinedResults is false and getShouldEnd returns false", () => { - const result = getGenerateOrRefineOrEndDecision({ - hasUnrefinedResults: false, - hasZeroAlerts: false, - maxHallucinationFailuresReached: false, - maxRetriesReached: false, - }); - - expect(result).toEqual('generate'); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts deleted file mode 100644 index b409f63f71a69..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getShouldEnd } from '../get_should_end'; - -export const getGenerateOrRefineOrEndDecision = ({ - hasUnrefinedResults, - hasZeroAlerts, - maxHallucinationFailuresReached, - maxRetriesReached, -}: { - hasUnrefinedResults: boolean; - hasZeroAlerts: boolean; - maxHallucinationFailuresReached: boolean; - maxRetriesReached: boolean; -}): 'end' | 'generate' | 'refine' => { - if (getShouldEnd({ hasZeroAlerts, maxHallucinationFailuresReached, maxRetriesReached })) { - return 'end'; - } else if (hasUnrefinedResults) { - return 'refine'; - } else { - return 'generate'; - } -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts deleted file mode 100644 index 82480a6ad6889..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getShouldEnd } from '.'; - -describe('getShouldEnd', () => { - it('returns true if hasZeroAlerts is true', () => { - const result = getShouldEnd({ - hasZeroAlerts: true, // <-- true - maxHallucinationFailuresReached: false, - maxRetriesReached: false, - }); - - expect(result).toBe(true); - }); - - it('returns true if maxHallucinationFailuresReached is true', () => { - const result = getShouldEnd({ - hasZeroAlerts: false, - maxHallucinationFailuresReached: true, // <-- true - maxRetriesReached: false, - }); - - expect(result).toBe(true); - }); - - it('returns true if maxRetriesReached is true', () => { - const result = getShouldEnd({ - hasZeroAlerts: false, - maxHallucinationFailuresReached: false, - maxRetriesReached: true, // <-- true - }); - - expect(result).toBe(true); - }); - - it('returns false if all conditions are false', () => { - const result = getShouldEnd({ - hasZeroAlerts: false, - maxHallucinationFailuresReached: false, - maxRetriesReached: false, - }); - - expect(result).toBe(false); - }); - - it('returns true if all conditions are true', () => { - const result = getShouldEnd({ - hasZeroAlerts: true, - maxHallucinationFailuresReached: true, - maxRetriesReached: true, - }); - - expect(result).toBe(true); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts deleted file mode 100644 index 9724ba25886fa..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getShouldEnd = ({ - hasZeroAlerts, - maxHallucinationFailuresReached, - maxRetriesReached, -}: { - hasZeroAlerts: boolean; - maxHallucinationFailuresReached: boolean; - maxRetriesReached: boolean; -}): boolean => hasZeroAlerts || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts deleted file mode 100644 index 585a1bc2dcac3..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { loggerMock } from '@kbn/logging-mocks'; - -import { getGenerateOrRefineOrEndEdge } from '.'; -import type { GraphState } from '../../types'; - -const logger = loggerMock.create(); - -const graphState: GraphState = { - attackDiscoveries: null, - attackDiscoveryPrompt: 'prompt', - anonymizedAlerts: [ - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - { - metadata: {}, - pageContent: - '@timestamp,2024-10-10T21:01:24.148Z\n' + - '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + - 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + - 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', - }, - ], - combinedGenerations: '', - combinedRefinements: '', - errors: [], - generationAttempts: 0, - generations: [], - hallucinationFailures: 0, - maxGenerationAttempts: 10, - maxHallucinationFailures: 5, - maxRepeatedGenerations: 3, - refinements: [], - refinePrompt: 'refinePrompt', - replacements: {}, - unrefinedResults: null, -}; - -describe('getGenerateOrRefineOrEndEdge', () => { - beforeEach(() => jest.clearAllMocks()); - - it('returns "end" when there are zero alerts', () => { - const withZeroAlerts: GraphState = { - ...graphState, - anonymizedAlerts: [], // <-- zero alerts - }; - - const edge = getGenerateOrRefineOrEndEdge(logger); - const result = edge(withZeroAlerts); - - expect(result).toEqual('end'); - }); - - it('returns "end" when max hallucination failures are reached', () => { - const withMaxHallucinationFailures: GraphState = { - ...graphState, - hallucinationFailures: 5, - }; - - const edge = getGenerateOrRefineOrEndEdge(logger); - const result = edge(withMaxHallucinationFailures); - - expect(result).toEqual('end'); - }); - - it('returns "end" when max retries are reached', () => { - const withMaxRetries: GraphState = { - ...graphState, - generationAttempts: 10, - }; - - const edge = getGenerateOrRefineOrEndEdge(logger); - const result = edge(withMaxRetries); - - expect(result).toEqual('end'); - }); - - it('returns refine when there are unrefined results', () => { - const withUnrefinedResults: GraphState = { - ...graphState, - unrefinedResults: [ - { - alertIds: [], - id: 'test-id', - detailsMarkdown: 'test-details', - entitySummaryMarkdown: 'test-summary', - summaryMarkdown: 'test-summary', - title: 'test-title', - timestamp: '2024-10-10T21:01:24.148Z', - }, - ], - }; - - const edge = getGenerateOrRefineOrEndEdge(logger); - const result = edge(withUnrefinedResults); - - expect(result).toEqual('refine'); - }); - - it('return generate when there are no unrefined results', () => { - const edge = getGenerateOrRefineOrEndEdge(logger); - const result = edge(graphState); - - expect(result).toEqual('generate'); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts deleted file mode 100644 index 3368a04ec9204..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; - -import { getGenerateOrRefineOrEndDecision } from './helpers/get_generate_or_refine_or_end_decision'; -import { getHasResults } from '../helpers/get_has_results'; -import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; -import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; -import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; -import type { GraphState } from '../../types'; - -export const getGenerateOrRefineOrEndEdge = (logger?: Logger) => { - const edge = (state: GraphState): 'end' | 'generate' | 'refine' => { - logger?.debug(() => '---GENERATE OR REFINE OR END---'); - const { - anonymizedAlerts, - generationAttempts, - hallucinationFailures, - maxGenerationAttempts, - maxHallucinationFailures, - unrefinedResults, - } = state; - - const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); - const hasUnrefinedResults = getHasResults(unrefinedResults); - const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); - const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ - hallucinationFailures, - maxHallucinationFailures, - }); - - const decision = getGenerateOrRefineOrEndDecision({ - hasUnrefinedResults, - hasZeroAlerts, - maxHallucinationFailuresReached, - maxRetriesReached, - }); - - logger?.debug( - () => - `generatOrRefineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( - { - anonymizedAlerts: anonymizedAlerts.length, - generationAttempts, - hallucinationFailures, - hasUnrefinedResults, - hasZeroAlerts, - maxHallucinationFailuresReached, - maxRetriesReached, - unrefinedResults: unrefinedResults?.length ?? 0, - }, - null, - 2 - )} - \n---GENERATE OR REFINE OR END: ${decision}---` - ); - return decision; - }; - - return edge; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts deleted file mode 100644 index 413f01b74dece..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscovery } from '@kbn/elastic-assistant-common'; - -export const getHasResults = (attackDiscoveries: AttackDiscovery[] | null): boolean => - attackDiscoveries !== null; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts deleted file mode 100644 index d768b363f101e..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Document } from '@langchain/core/documents'; -import { isEmpty } from 'lodash/fp'; - -export const getHasZeroAlerts = (anonymizedAlerts: Document[]): boolean => - isEmpty(anonymizedAlerts); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts deleted file mode 100644 index 7168aa08aeef2..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getShouldEnd } from '../get_should_end'; - -export const getRefineOrEndDecision = ({ - hasFinalResults, - maxHallucinationFailuresReached, - maxRetriesReached, -}: { - hasFinalResults: boolean; - maxHallucinationFailuresReached: boolean; - maxRetriesReached: boolean; -}): 'refine' | 'end' => - getShouldEnd({ - hasFinalResults, - maxHallucinationFailuresReached, - maxRetriesReached, - }) - ? 'end' - : 'refine'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts deleted file mode 100644 index 697f93dd3a02f..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getShouldEnd = ({ - hasFinalResults, - maxHallucinationFailuresReached, - maxRetriesReached, -}: { - hasFinalResults: boolean; - maxHallucinationFailuresReached: boolean; - maxRetriesReached: boolean; -}): boolean => hasFinalResults || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts deleted file mode 100644 index 85140dceafdcb..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; - -import { getRefineOrEndDecision } from './helpers/get_refine_or_end_decision'; -import { getHasResults } from '../helpers/get_has_results'; -import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; -import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; -import type { GraphState } from '../../types'; - -export const getRefineOrEndEdge = (logger?: Logger) => { - const edge = (state: GraphState): 'end' | 'refine' => { - logger?.debug(() => '---REFINE OR END---'); - const { - attackDiscoveries, - generationAttempts, - hallucinationFailures, - maxGenerationAttempts, - maxHallucinationFailures, - } = state; - - const hasFinalResults = getHasResults(attackDiscoveries); - const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); - const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ - hallucinationFailures, - maxHallucinationFailures, - }); - - const decision = getRefineOrEndDecision({ - hasFinalResults, - maxHallucinationFailuresReached, - maxRetriesReached, - }); - - logger?.debug( - () => - `refineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( - { - attackDiscoveries: attackDiscoveries?.length ?? 0, - generationAttempts, - hallucinationFailures, - hasFinalResults, - maxHallucinationFailuresReached, - maxRetriesReached, - }, - null, - 2 - )} - \n---REFINE OR END: ${decision}---` - ); - - return decision; - }; - - return edge; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts deleted file mode 100644 index 050ca17484185..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Document } from '@langchain/core/documents'; - -export const getRetrieveOrGenerate = ( - anonymizedAlerts: Document[] -): 'retrieve_anonymized_alerts' | 'generate' => - anonymizedAlerts.length === 0 ? 'retrieve_anonymized_alerts' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts deleted file mode 100644 index ad0512497d07d..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; - -import { getRetrieveOrGenerate } from './get_retrieve_or_generate'; -import type { GraphState } from '../../types'; - -export const getRetrieveAnonymizedAlertsOrGenerateEdge = (logger?: Logger) => { - const edge = (state: GraphState): 'retrieve_anonymized_alerts' | 'generate' => { - logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS OR GENERATE---'); - const { anonymizedAlerts } = state; - - const decision = getRetrieveOrGenerate(anonymizedAlerts); - - logger?.debug( - () => - `retrieveAnonymizedAlertsOrGenerateEdge evaluated the following (derived) state:\n${JSON.stringify( - { - anonymizedAlerts: anonymizedAlerts.length, - }, - null, - 2 - )} - \n---RETRIEVE ANONYMIZED ALERTS OR GENERATE: ${decision}---` - ); - - return decision; - }; - - return edge; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts deleted file mode 100644 index 07985381afa73..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getMaxHallucinationFailuresReached = ({ - hallucinationFailures, - maxHallucinationFailures, -}: { - hallucinationFailures: number; - maxHallucinationFailures: number; -}): boolean => hallucinationFailures >= maxHallucinationFailures; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts deleted file mode 100644 index c1e36917b45cf..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getMaxRetriesReached = ({ - generationAttempts, - maxGenerationAttempts, -}: { - generationAttempts: number; - maxGenerationAttempts: number; -}): boolean => generationAttempts >= maxGenerationAttempts; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts deleted file mode 100644 index b2c90636ef523..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import { Replacements } from '@kbn/elastic-assistant-common'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import type { CompiledStateGraph } from '@langchain/langgraph'; -import { END, START, StateGraph } from '@langchain/langgraph'; - -import { NodeType } from './constants'; -import { getGenerateOrEndEdge } from './edges/generate_or_end'; -import { getGenerateOrRefineOrEndEdge } from './edges/generate_or_refine_or_end'; -import { getRefineOrEndEdge } from './edges/refine_or_end'; -import { getRetrieveAnonymizedAlertsOrGenerateEdge } from './edges/retrieve_anonymized_alerts_or_generate'; -import { getDefaultGraphState } from './state'; -import { getGenerateNode } from './nodes/generate'; -import { getRefineNode } from './nodes/refine'; -import { getRetrieveAnonymizedAlertsNode } from './nodes/retriever'; -import type { GraphState } from './types'; - -export interface GetDefaultAttackDiscoveryGraphParams { - alertsIndexPattern?: string; - anonymizationFields: AnonymizationFieldResponse[]; - esClient: ElasticsearchClient; - llm: ActionsClientLlm; - logger?: Logger; - onNewReplacements?: (replacements: Replacements) => void; - replacements?: Replacements; - size: number; -} - -export type DefaultAttackDiscoveryGraph = ReturnType; - -/** - * This function returns a compiled state graph that represents the default - * Attack discovery graph. - * - * Refer to the following diagram for this graph: - * x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png - */ -export const getDefaultAttackDiscoveryGraph = ({ - alertsIndexPattern, - anonymizationFields, - esClient, - llm, - logger, - onNewReplacements, - replacements, - size, -}: GetDefaultAttackDiscoveryGraphParams): CompiledStateGraph< - GraphState, - Partial, - 'generate' | 'refine' | 'retrieve_anonymized_alerts' | '__start__' -> => { - try { - const graphState = getDefaultGraphState(); - - // get nodes: - const retrieveAnonymizedAlertsNode = getRetrieveAnonymizedAlertsNode({ - alertsIndexPattern, - anonymizationFields, - esClient, - logger, - onNewReplacements, - replacements, - size, - }); - - const generateNode = getGenerateNode({ - llm, - logger, - }); - - const refineNode = getRefineNode({ - llm, - logger, - }); - - // get edges: - const generateOrEndEdge = getGenerateOrEndEdge(logger); - - const generatOrRefineOrEndEdge = getGenerateOrRefineOrEndEdge(logger); - - const refineOrEndEdge = getRefineOrEndEdge(logger); - - const retrieveAnonymizedAlertsOrGenerateEdge = - getRetrieveAnonymizedAlertsOrGenerateEdge(logger); - - // create the graph: - const graph = new StateGraph({ channels: graphState }) - .addNode(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, retrieveAnonymizedAlertsNode) - .addNode(NodeType.GENERATE_NODE, generateNode) - .addNode(NodeType.REFINE_NODE, refineNode) - .addConditionalEdges(START, retrieveAnonymizedAlertsOrGenerateEdge, { - generate: NodeType.GENERATE_NODE, - retrieve_anonymized_alerts: NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, - }) - .addConditionalEdges(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, generateOrEndEdge, { - end: END, - generate: NodeType.GENERATE_NODE, - }) - .addConditionalEdges(NodeType.GENERATE_NODE, generatOrRefineOrEndEdge, { - end: END, - generate: NodeType.GENERATE_NODE, - refine: NodeType.REFINE_NODE, - }) - .addConditionalEdges(NodeType.REFINE_NODE, refineOrEndEdge, { - end: END, - refine: NodeType.REFINE_NODE, - }); - - // compile the graph: - return graph.compile(); - } catch (e) { - throw new Error(`Unable to compile AttackDiscoveryGraph\n${e}`); - } -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts deleted file mode 100644 index ed5549acc586a..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const mockEmptyOpenAndAcknowledgedAlertsQueryResults = { - took: 0, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 0, - relation: 'eq', - }, - max_score: null, - hits: [], - }, -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts deleted file mode 100644 index 3f22f787f54f8..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts +++ /dev/null @@ -1,1396 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const mockOpenAndAcknowledgedAlertsQueryResults = { - took: 13, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 31, - relation: 'eq', - }, - max_score: null, - hits: [ - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['/Users/james/unix1'], - 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': [ - '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', - ], - 'process.parent.name': ['unix1'], - 'user.name': ['james'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', - ], - 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], - 'process.pid': [1227], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': [ - 'code failed to satisfy specified code requirement(s)', - ], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.72442], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': ['/Users/james/unix1'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': [''], - 'process.parent.executable': ['/Users/james/unix1'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['unix1'], - 'process.args': [ - '/Users/james/unix1', - '/Users/james/library/Keychains/login.keychain-db', - 'TempTemp1234!!', - ], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [3], - 'process.name': ['unix1'], - 'process.parent.args': [ - '/Users/james/unix1', - '/Users/james/library/Keychains/login.keychain-db', - 'TempTemp1234!!', - ], - '@timestamp': ['2024-05-07T12:48:45.032Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': [ - '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', - ], - 'host.risk.calculated_level': ['High'], - _id: ['b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560'], - 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:39.368Z'], - }, - sort: [99, 1715086125032], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['/Users/james/unix1'], - 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], - 'event.category': ['malware', 'intrusion_detection', 'file'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.parent.name': ['My Go Application.app'], - 'user.name': ['james'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', - ], - 'process.code_signature.signing_id': ['a.out'], - 'process.pid': [1220], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': [ - 'code failed to satisfy specified code requirement(s)', - ], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.72442], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': [''], - 'process.parent.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['unix1'], - 'process.args': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['My Go Application.app'], - 'process.parent.args': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - '@timestamp': ['2024-05-07T12:48:45.030Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'host.risk.calculated_level': ['High'], - _id: ['0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367'], - 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:38.061Z'], - }, - sort: [99, 1715086125030], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['/Users/james/unix1'], - 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.parent.name': ['My Go Application.app'], - 'user.name': ['james'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', - ], - 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], - 'process.pid': [1220], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': [ - 'code failed to satisfy specified code requirement(s)', - ], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.72442], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': ['/Users/james/unix1'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': [''], - 'process.parent.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['unix1'], - 'process.args': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['unix1'], - 'process.parent.args': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - '@timestamp': ['2024-05-07T12:48:45.029Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'host.risk.calculated_level': ['High'], - _id: ['600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a'], - 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:37.881Z'], - }, - sort: [99, 1715086125029], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['/Users/james/unix1'], - 'process.hash.md5': ['3f19892ab44eb9bc7bc03f438944301e'], - 'event.category': ['malware', 'intrusion_detection', 'file'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.parent.name': ['My Go Application.app'], - 'user.name': ['james'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - 'f80234ff6fed2c62d23f37443f2412fbe806711b6add2ac126e03e282082c8f5', - ], - 'process.code_signature.signing_id': ['com.apple.chmod'], - 'process.pid': [1219], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': [ - 'code failed to satisfy specified code requirement(s)', - ], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Software Signing'], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.72442], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': ['/bin/chmod'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'process.parent.code_signature.subject_name': [''], - 'process.parent.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['unix1'], - 'process.args': ['chmod', '777', '/Users/james/unix1'], - 'process.code_signature.status': ['No error.'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['chmod'], - 'process.parent.args': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - '@timestamp': ['2024-05-07T12:48:45.028Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': ['chmod 777 /Users/james/unix1'], - 'host.risk.calculated_level': ['High'], - _id: ['e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c'], - 'process.hash.sha1': ['217490d4f51717aa3b301abec96be08602370d2d'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:37.869Z'], - }, - sort: [99, 1715086125028], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['643dddff1a57cbf70594854b44eb1a1d'], - 'event.category': ['malware', 'intrusion_detection'], - 'host.risk.calculated_score_norm': [73.02488], - 'rule.reference': [ - 'https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/collection/osx/prompt.py', - 'https://ss64.com/osx/osascript.html', - ], - 'process.parent.name': ['My Go Application.app'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - 'bab17feba710b469e5d96820f0cb7ed511d983e5817f374ec3cb46462ac5b794', - ], - 'process.pid': [1206], - 'process.code_signature.exists': [true], - 'process.code_signature.subject_name': ['Software Signing'], - 'host.os.version': ['13.4'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.72442], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': [ - 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', - ], - 'host.name': ['SRVMAC08'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'group.name': ['staff'], - 'kibana.alert.workflow_status': ['open'], - 'rule.name': ['Potential Credentials Phishing via OSASCRIPT'], - 'threat.tactic.id': ['TA0006'], - 'threat.tactic.name': ['Credential Access'], - 'threat.technique.id': ['T1056'], - 'process.parent.args_count': [0], - 'threat.technique.subtechnique.reference': [ - 'https://attack.mitre.org/techniques/T1056/002/', - ], - 'process.name': ['osascript'], - 'threat.technique.subtechnique.name': ['GUI Input Capture'], - 'process.parent.code_signature.trusted': [false], - _id: ['2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f'], - 'threat.technique.name': ['Input Capture'], - 'group.id': ['20'], - 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0006/'], - 'user.name': ['james'], - 'threat.framework': ['MITRE ATT&CK'], - 'process.code_signature.signing_id': ['com.apple.osascript'], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': [ - 'code failed to satisfy specified code requirement(s)', - ], - 'event.module': ['endpoint'], - 'process.executable': ['/usr/bin/osascript'], - 'process.parent.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.args': [ - 'osascript', - '-e', - 'display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', - ], - 'process.code_signature.status': ['No error.'], - message: [ - 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', - ], - '@timestamp': ['2024-05-07T12:48:45.027Z'], - 'threat.technique.subtechnique.id': ['T1056.002'], - 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1056/'], - 'process.command_line': [ - 'osascript -e display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', - ], - 'host.risk.calculated_level': ['High'], - 'process.hash.sha1': ['0568baae15c752208ae56d8f9c737976d6de2e3a'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:09.909Z'], - }, - sort: [99, 1715086125027], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': ['/sbin/launchd'], - 'process.parent.name': ['launchd'], - 'user.name': ['root'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', - ], - 'process.code_signature.signing_id': ['a.out'], - 'process.pid': [1200], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['No error.'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.491455], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': ['Software Signing'], - 'process.parent.executable': ['/sbin/launchd'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['My Go Application.app'], - 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['My Go Application.app'], - 'process.parent.args': ['/sbin/launchd'], - '@timestamp': ['2024-05-07T12:48:45.023Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': [ - 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', - ], - 'host.risk.calculated_level': ['High'], - _id: ['2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69'], - 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:28:06.888Z'], - }, - sort: [99, 1715086125023], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': ['/sbin/launchd'], - 'process.parent.name': ['launchd'], - 'user.name': ['root'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', - ], - 'process.code_signature.signing_id': ['a.out'], - 'process.pid': [1169], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['No error.'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.491455], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': ['Software Signing'], - 'process.parent.executable': ['/sbin/launchd'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['My Go Application.app'], - 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['My Go Application.app'], - 'process.parent.args': ['/sbin/launchd'], - '@timestamp': ['2024-05-07T12:48:45.022Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': [ - 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', - ], - 'host.risk.calculated_level': ['High'], - _id: ['4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd'], - 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:27:47.362Z'], - }, - sort: [99, 1715086125022], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': ['/sbin/launchd'], - 'process.parent.name': ['launchd'], - 'user.name': ['root'], - 'user.risk.calculated_level': ['Moderate'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', - ], - 'process.code_signature.signing_id': ['a.out'], - 'process.pid': [1123], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['No error.'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': [''], - 'host.os.version': ['13.4'], - 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [66.491455], - 'host.os.name': ['macOS'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVMAC08'], - 'process.executable': [ - '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.parent.code_signature.subject_name': ['Software Signing'], - 'process.parent.executable': ['/sbin/launchd'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['My Go Application.app'], - 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], - 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['My Go Application.app'], - 'process.parent.args': ['/sbin/launchd'], - '@timestamp': ['2024-05-07T12:48:45.020Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': [ - 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', - ], - 'host.risk.calculated_level': ['High'], - _id: ['449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db'], - 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-06-19T00:25:24.716Z'], - }, - sort: [99, 1715086125020], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'kibana.alert.workflow_status': ['open'], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Memory Threat Detection Alert: Shellcode Injection'], - 'process.parent.args_count': [1], - 'process.name': ['MsMpEng.exe'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:45.017Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - _id: ['f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:22.051Z'], - }, - sort: [99, 1715086125017], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['C:\\Windows\\mpsvc.dll'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection', 'library'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['mpsvc.dll'], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['MsMpEng.exe'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:45.008Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - _id: ['aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:18.093Z'], - }, - sort: [99, 1715086125008], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['C:\\Windows\\mpsvc.dll'], - 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], - 'event.category': ['malware', 'intrusion_detection', 'file'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], - 'process.parent.name': ['explorer.exe'], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', - ], - 'process.pid': [1008], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['trusted'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['Microsoft Windows'], - 'process.parent.executable': ['C:\\Windows\\explorer.exe'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['mpsvc.dll'], - 'process.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'process.code_signature.status': ['errorExpired'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], - 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], - '@timestamp': ['2024-05-07T12:48:45.007Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'host.risk.calculated_level': ['High'], - _id: ['dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515'], - 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:17.887Z'], - }, - sort: [99, 1715086125007], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], - 'process.parent.name': ['explorer.exe'], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', - ], - 'process.pid': [1008], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['trusted'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [false], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['Microsoft Windows'], - 'process.parent.executable': ['C:\\Windows\\explorer.exe'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], - 'process.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'process.code_signature.status': ['errorExpired'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], - 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], - '@timestamp': ['2024-05-07T12:48:45.006Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'host.risk.calculated_level': ['High'], - _id: ['f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a'], - 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:17.544Z'], - }, - sort: [99, 1715086125006], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], - 'event.category': ['malware', 'intrusion_detection', 'file'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [''], - 'process.parent.name': ['userinit.exe'], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', - ], - 'process.pid': [6228], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['trusted'], - 'process.pe.original_file_name': ['EXPLORER.EXE'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Windows'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\explorer.exe'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['Microsoft Windows'], - 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], - 'process.args': ['C:\\Windows\\Explorer.EXE'], - 'process.code_signature.status': ['trusted'], - message: ['Malware Detection Alert'], - 'process.name': ['explorer.exe'], - '@timestamp': ['2024-05-07T12:48:45.004Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': ['C:\\Windows\\Explorer.EXE'], - 'host.risk.calculated_level': ['High'], - _id: ['6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66'], - 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:37:18.152Z'], - }, - sort: [99, 1715086125004], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', - ], - 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], - 'event.category': ['malware', 'intrusion_detection', 'file'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [''], - 'process.parent.name': ['userinit.exe'], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', - ], - 'process.pid': [6228], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['trusted'], - 'process.pe.original_file_name': ['EXPLORER.EXE'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Windows'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\explorer.exe'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['Microsoft Windows'], - 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], - 'process.args': ['C:\\Windows\\Explorer.EXE'], - 'process.code_signature.status': ['trusted'], - message: ['Malware Detection Alert'], - 'process.name': ['explorer.exe'], - '@timestamp': ['2024-05-07T12:48:45.001Z'], - 'process.parent.code_signature.trusted': [true], - 'process.command_line': ['C:\\Windows\\Explorer.EXE'], - 'host.risk.calculated_level': ['High'], - _id: ['ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316'], - 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:36:43.813Z'], - }, - sort: [99, 1715086125001], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection', 'process', 'file'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Ransomware Detection Alert'], - 'host.name': ['SRVWIN02'], - 'Ransomware.files.data': [ - '2D002D002D003D003D003D0020005700', - '2D002D002D003D003D003D0020005700', - '2D002D002D003D003D003D0020005700', - ], - 'process.code_signature.trusted': [true], - 'Ransomware.files.metrics': ['CANARY_ACTIVITY'], - 'kibana.alert.workflow_status': ['open'], - 'process.parent.args_count': [1], - 'process.name': ['MsMpEng.exe'], - 'Ransomware.files.score': [0, 0, 0], - 'process.parent.code_signature.trusted': [false], - _id: ['0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f'], - 'Ransomware.version': ['1.6.0'], - 'user.name': ['Administrator'], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'Ransomware.files.operation': ['creation', 'creation', 'creation'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'process.Ext.token.integrity_level_name': ['high'], - 'Ransomware.files.path': [ - 'c:\\hd3vuk19y-readme.txt', - 'c:\\$winreagent\\hd3vuk19y-readme.txt', - 'c:\\aaantiransomelastic-do-not-touch-dab6d40c-a6a1-442c-adc4-9d57a47e58d7\\hd3vuk19y-readme.txt', - ], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'Ransomware.files.entropy': [3.629971457026797, 3.629971457026797, 3.629971457026797], - 'Ransomware.feature': ['canary'], - 'Ransomware.files.extension': ['txt', 'txt', 'txt'], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Ransomware Detection Alert'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:45.000Z'], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:22.964Z'], - }, - sort: [99, 1715086125000], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'kibana.alert.workflow_status': ['open'], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Memory Threat Detection Alert: Shellcode Injection'], - 'process.parent.args_count': [1], - 'process.name': ['MsMpEng.exe'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:44.996Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - _id: ['b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:22.174Z'], - }, - sort: [99, 1715086124996], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], - 'host.name': ['SRVWIN02'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'kibana.alert.workflow_status': ['open'], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Memory Threat Detection Alert: Shellcode Injection'], - 'process.parent.args_count': [1], - 'process.name': ['MsMpEng.exe'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:44.986Z'], - 'process.parent.code_signature.trusted': [false], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - _id: ['7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:22.066Z'], - }, - sort: [99, 1715086124986], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], - 'event.category': ['malware', 'intrusion_detection', 'process'], - 'host.risk.calculated_score_norm': [75.62723], - 'process.parent.command_line': [ - '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', - ], - 'process.parent.name': [ - 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', - ], - 'process.Ext.memory_region.malware_signature.primary.matches': [ - 'WVmF9nQli1UIg2YEAIk+iwoLSgQ=', - 'dQxy0zPAQF9eW4vlXcMzwOv1VYvsgw==', - 'DIsEsIN4BAV1HP9wCP9wDP91DP8=', - '+4tF/FCLCP9RCF6Lx19bi+Vdw1U=', - 'vAAAADPSi030i/GLRfAPpMEBwe4f', - 'VIvO99GLwiNN3PfQM030I8czReiJ', - 'DIlGDIXAdSozwOtsi0YIhcB0Yms=', - ], - 'process.pid': [8708], - 'process.code_signature.exists': [true], - 'process.code_signature.subject_name': ['Microsoft Corporation'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': [ - 'Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi', - ], - 'host.name': ['SRVWIN02'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'kibana.alert.workflow_status': ['open'], - 'rule.name': ['Windows.Ransomware.Sodinokibi'], - 'process.parent.args_count': [1], - 'process.Ext.memory_region.bytes_compressed_present': [false], - 'process.name': ['MsMpEng.exe'], - 'process.parent.code_signature.trusted': [false], - _id: ['ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e'], - 'user.name': ['Administrator'], - 'process.parent.code_signature.exists': [true], - 'process.parent.code_signature.status': ['errorExpired'], - 'process.pe.original_file_name': ['MsMpEng.exe'], - 'event.module': ['endpoint'], - 'process.Ext.memory_region.malware_signature.all_names': [ - 'Windows.Ransomware.Sodinokibi', - ], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\MsMpEng.exe'], - 'process.Ext.memory_region.malware_signature.primary.signature.name': [ - 'Windows.Ransomware.Sodinokibi', - ], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], - 'process.parent.executable': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - 'process.args': ['C:\\Windows\\MsMpEng.exe'], - 'process.code_signature.status': ['trusted'], - message: ['Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi'], - 'process.parent.args': [ - 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', - ], - '@timestamp': ['2024-05-07T12:48:44.975Z'], - 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], - 'host.risk.calculated_level': ['High'], - 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-20T23:38:25.169Z'], - }, - sort: [99, 1715086124975], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: 'cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'file.path': ['C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll'], - 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], - 'event.category': ['malware', 'intrusion_detection', 'library'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], - 'process.parent.name': ['Q3C7N1V8.exe'], - 'user.name': ['Administrator'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', - ], - 'process.pid': [7824], - 'process.code_signature.exists': [true], - 'process.parent.code_signature.exists': [false], - 'process.pe.original_file_name': ['RUNDLL32.EXE'], - 'event.module': ['endpoint'], - 'process.code_signature.subject_name': ['Microsoft Windows'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'file.hash.sha256': ['12e6642cf6413bdf5388bee663080fa299591b2ba023d069286f3be9647547c8'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': ['Malware Detection Alert'], - 'host.name': ['SRVWIN01'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], - 'kibana.alert.workflow_status': ['open'], - 'file.name': ['cdnver.dll'], - 'process.args': [ - 'C:\\Windows\\System32\\rundll32.exe', - 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', - ], - 'process.code_signature.status': ['trusted'], - message: ['Malware Detection Alert'], - 'process.parent.args_count': [1], - 'process.name': ['rundll32.exe'], - 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], - '@timestamp': ['2024-05-07T12:47:32.838Z'], - 'process.command_line': [ - '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', - ], - 'host.risk.calculated_level': ['High'], - _id: ['cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b'], - 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-16T01:51:26.472Z'], - }, - sort: [99, 1715086052838], - }, - { - _index: '.internal.alerts-security.alerts-default-000001', - _id: '6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3', - _score: null, - fields: { - 'kibana.alert.severity': ['critical'], - 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], - 'event.category': ['malware', 'intrusion_detection'], - 'host.risk.calculated_score_norm': [73.02488], - 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], - 'process.parent.name': ['Q3C7N1V8.exe'], - 'user.risk.calculated_level': ['High'], - 'kibana.alert.rule.description': [ - 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', - ], - 'process.hash.sha256': [ - '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', - ], - 'process.pid': [7824], - 'process.code_signature.exists': [true], - 'process.code_signature.subject_name': ['Microsoft Windows'], - 'host.os.version': ['21H2 (10.0.20348.1366)'], - 'kibana.alert.risk_score': [99], - 'user.risk.calculated_score_norm': [82.16188], - 'host.os.name': ['Windows'], - 'kibana.alert.rule.name': [ - 'Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments', - ], - 'host.name': ['SRVWIN01'], - 'event.outcome': ['success'], - 'process.code_signature.trusted': [true], - 'kibana.alert.workflow_status': ['open'], - 'rule.name': ['RunDLL32 with Unusual Arguments'], - 'threat.tactic.id': ['TA0005'], - 'threat.tactic.name': ['Defense Evasion'], - 'threat.technique.id': ['T1218'], - 'process.parent.args_count': [1], - 'threat.technique.subtechnique.reference': [ - 'https://attack.mitre.org/techniques/T1218/011/', - ], - 'process.name': ['rundll32.exe'], - 'threat.technique.subtechnique.name': ['Rundll32'], - _id: ['6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3'], - 'threat.technique.name': ['System Binary Proxy Execution'], - 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0005/'], - 'user.name': ['Administrator'], - 'threat.framework': ['MITRE ATT&CK'], - 'process.working_directory': ['C:\\Users\\Administrator\\Documents\\'], - 'process.pe.original_file_name': ['RUNDLL32.EXE'], - 'event.module': ['endpoint'], - 'user.domain': ['OMM-WIN-DETECT'], - 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], - 'process.Ext.token.integrity_level_name': ['high'], - 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], - 'process.args': [ - 'C:\\Windows\\System32\\rundll32.exe', - 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', - ], - 'process.code_signature.status': ['trusted'], - message: ['Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments'], - 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], - '@timestamp': ['2024-05-07T12:47:32.836Z'], - 'threat.technique.subtechnique.id': ['T1218.011'], - 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1218/'], - 'process.command_line': [ - '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', - ], - 'host.risk.calculated_level': ['High'], - 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], - 'event.dataset': ['endpoint.alerts'], - 'kibana.alert.original_time': ['2023-01-16T01:51:26.348Z'], - }, - sort: [99, 1715086052836], - }, - ], - }, -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts deleted file mode 100644 index a40dde44f8d67..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { GraphState } from '../../../../types'; - -export const discardPreviousGenerations = ({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected, - state, -}: { - generationAttempts: number; - hallucinationFailures: number; - isHallucinationDetected: boolean; - state: GraphState; -}): GraphState => { - return { - ...state, - combinedGenerations: '', // <-- reset the combined generations - generationAttempts: generationAttempts + 1, - generations: [], // <-- reset the generations - hallucinationFailures: isHallucinationDetected - ? hallucinationFailures + 1 - : hallucinationFailures, - }; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts deleted file mode 100644 index d92d935053577..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. -export const getAlertsContextPrompt = ({ - anonymizedAlerts, - attackDiscoveryPrompt, -}: { - anonymizedAlerts: string[]; - attackDiscoveryPrompt: string; -}) => `${attackDiscoveryPrompt} - -Use context from the following alerts to provide insights: - -""" -${anonymizedAlerts.join('\n\n')} -""" -`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts deleted file mode 100644 index fb7cf6bd59f98..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { GraphState } from '../../../../types'; - -export const getAnonymizedAlertsFromState = (state: GraphState): string[] => - state.anonymizedAlerts.map((doc) => doc.pageContent); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts deleted file mode 100644 index face2a6afc6bc..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscovery } from '@kbn/elastic-assistant-common'; - -import { getMaxRetriesReached } from '../../../../helpers/get_max_retries_reached'; - -export const getUseUnrefinedResults = ({ - generationAttempts, - maxGenerationAttempts, - unrefinedResults, -}: { - generationAttempts: number; - maxGenerationAttempts: number; - unrefinedResults: AttackDiscovery[] | null; -}): boolean => { - const nextAttemptWouldExcedLimit = getMaxRetriesReached({ - generationAttempts: generationAttempts + 1, // + 1, because we just used an attempt - maxGenerationAttempts, - }); - - return nextAttemptWouldExcedLimit && unrefinedResults != null && unrefinedResults.length > 0; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts deleted file mode 100644 index 1fcd81622f0fe..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import type { Logger } from '@kbn/core/server'; - -import { discardPreviousGenerations } from './helpers/discard_previous_generations'; -import { extractJson } from '../helpers/extract_json'; -import { getAnonymizedAlertsFromState } from './helpers/get_anonymized_alerts_from_state'; -import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; -import { getCombined } from '../helpers/get_combined'; -import { getCombinedAttackDiscoveryPrompt } from '../helpers/get_combined_attack_discovery_prompt'; -import { generationsAreRepeating } from '../helpers/generations_are_repeating'; -import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; -import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; -import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; -import type { GraphState } from '../../types'; - -export const getGenerateNode = ({ - llm, - logger, -}: { - llm: ActionsClientLlm; - logger?: Logger; -}): ((state: GraphState) => Promise) => { - const generate = async (state: GraphState): Promise => { - logger?.debug(() => `---GENERATE---`); - - const anonymizedAlerts: string[] = getAnonymizedAlertsFromState(state); - - const { - attackDiscoveryPrompt, - combinedGenerations, - generationAttempts, - generations, - hallucinationFailures, - maxGenerationAttempts, - maxRepeatedGenerations, - } = state; - - let combinedResponse = ''; // mutable, because it must be accessed in the catch block - let partialResponse = ''; // mutable, because it must be accessed in the catch block - - try { - const query = getCombinedAttackDiscoveryPrompt({ - anonymizedAlerts, - attackDiscoveryPrompt, - combinedMaybePartialResults: combinedGenerations, - }); - - const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); - - logger?.debug( - () => `generate node is invoking the chain (${llmType}), attempt ${generationAttempts}` - ); - - const rawResponse = (await chain.invoke({ - format_instructions: formatInstructions, - query, - })) as unknown as string; - - // LOCAL MUTATION: - partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` - - // if the response is hallucinated, discard previous generations and start over: - if (responseIsHallucinated(partialResponse)) { - logger?.debug( - () => - `generate node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated generations and starting over` - ); - - return discardPreviousGenerations({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected: true, - state, - }); - } - - // if the generations are repeating, discard previous generations and start over: - if ( - generationsAreRepeating({ - currentGeneration: partialResponse, - previousGenerations: generations, - sampleLastNGenerations: maxRepeatedGenerations, - }) - ) { - logger?.debug( - () => - `generate node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` - ); - - // discard the accumulated results and start over: - return discardPreviousGenerations({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected: false, - state, - }); - } - - // LOCAL MUTATION: - combinedResponse = getCombined({ combinedGenerations, partialResponse }); // combine the new response with the previous ones - - const unrefinedResults = parseCombinedOrThrow({ - combinedResponse, - generationAttempts, - llmType, - logger, - nodeName: 'generate', - }); - - // use the unrefined results if we already reached the max number of retries: - const useUnrefinedResults = getUseUnrefinedResults({ - generationAttempts, - maxGenerationAttempts, - unrefinedResults, - }); - - if (useUnrefinedResults) { - logger?.debug( - () => - `generate node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` - ); - } - - return { - ...state, - attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, // optionally skip the refinement step by returning the final answer - combinedGenerations: combinedResponse, - generationAttempts: generationAttempts + 1, - generations: [...generations, partialResponse], - unrefinedResults, - }; - } catch (error) { - const parsingError = `generate node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; - logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response - - return { - ...state, - combinedGenerations: combinedResponse, - errors: [...state.errors, parsingError], - generationAttempts: generationAttempts + 1, - generations: [...generations, partialResponse], - }; - } - }; - - return generate; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts deleted file mode 100644 index 05210799f151c..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { z } from '@kbn/zod'; - -export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; -const GOOD_SYNTAX_EXAMPLES = - 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; - -const BAD_SYNTAX_EXAMPLES = - 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; - -const RECONNAISSANCE = 'Reconnaissance'; -const INITIAL_ACCESS = 'Initial Access'; -const EXECUTION = 'Execution'; -const PERSISTENCE = 'Persistence'; -const PRIVILEGE_ESCALATION = 'Privilege Escalation'; -const DISCOVERY = 'Discovery'; -const LATERAL_MOVEMENT = 'Lateral Movement'; -const COMMAND_AND_CONTROL = 'Command and Control'; -const EXFILTRATION = 'Exfiltration'; - -const MITRE_ATTACK_TACTICS = [ - RECONNAISSANCE, - INITIAL_ACCESS, - EXECUTION, - PERSISTENCE, - PRIVILEGE_ESCALATION, - DISCOVERY, - LATERAL_MOVEMENT, - COMMAND_AND_CONTROL, - EXFILTRATION, -] as const; - -export const AttackDiscoveriesGenerationSchema = z.object({ - insights: z - .array( - z.object({ - alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), - detailsMarkdown: z - .string() - .describe( - `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ), - entitySummaryMarkdown: z - .string() - .optional() - .describe( - `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` - ), - mitreAttackTactics: z - .string() - .array() - .optional() - .describe( - `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( - ',' - )}` - ), - summaryMarkdown: z - .string() - .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), - title: z - .string() - .describe( - 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' - ), - }) - ) - .describe( - `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ), -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts deleted file mode 100644 index fd824709f5fcf..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const addTrailingBackticksIfNecessary = (text: string): string => { - const leadingJSONpattern = /^\w*```json(.*?)/s; - const trailingBackticksPattern = /(.*?)```\w*$/s; - - const hasLeadingJSONWrapper = leadingJSONpattern.test(text); - const hasTrailingBackticks = trailingBackticksPattern.test(text); - - if (hasLeadingJSONWrapper && !hasTrailingBackticks) { - return `${text}\n\`\`\``; - } - - return text; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts deleted file mode 100644 index 5e13ec9f0dafe..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { extractJson } from '.'; - -describe('extractJson', () => { - it('returns the JSON text surrounded by ```json and ``` with no whitespace or additional text', () => { - const input = '```json{"key": "value"}```'; - - const expected = '{"key": "value"}'; - - expect(extractJson(input)).toBe(expected); - }); - - it('returns the JSON block when surrounded by additional text and whitespace', () => { - const input = - 'You asked for some JSON, here it is:\n```json\n{"key": "value"}\n```\nI hope that works for you.'; - - const expected = '{"key": "value"}'; - - expect(extractJson(input)).toBe(expected); - }); - - it('returns the original text if no JSON block is found', () => { - const input = "There's no JSON here, just some text."; - - expect(extractJson(input)).toBe(input); - }); - - it('trims leading and trailing whitespace from the extracted JSON', () => { - const input = 'Text before\n```json\n {"key": "value"} \n```\nText after'; - - const expected = '{"key": "value"}'; - - expect(extractJson(input)).toBe(expected); - }); - - it('handles incomplete JSON blocks with no trailing ```', () => { - const input = 'Text before\n```json\n{"key": "value"'; // <-- no closing ```, because incomplete generation - - expect(extractJson(input)).toBe('{"key": "value"'); - }); - - it('handles multiline json (real world edge case)', () => { - const input = - '```json\n{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}\n```'; - - const expected = - '{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}'; - - expect(extractJson(input)).toBe(expected); - }); - - it('handles "Here is my analysis of the security events in JSON format" (real world edge case)', () => { - const input = - 'Here is my analysis of the security events in JSON format:\n\n```json\n{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; - - const expected = - '{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; - - expect(extractJson(input)).toBe(expected); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts deleted file mode 100644 index 79d3f9c0d0599..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const extractJson = (input: string): string => { - const regex = /```json\s*([\s\S]*?)(?:\s*```|$)/; - const match = input.match(regex); - - if (match && match[1]) { - return match[1].trim(); - } - - return input; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx deleted file mode 100644 index 7d6db4dd72dfd..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { generationsAreRepeating } from '.'; - -describe('getIsGenerationRepeating', () => { - it('returns true when all previous generations are the same as the current generation', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: ['gen1', 'gen1', 'gen1'], // <-- all the same, length 3 - sampleLastNGenerations: 3, - }); - - expect(result).toBe(true); - }); - - it('returns false when some of the previous generations are NOT the same as the current generation', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: ['gen1', 'gen2', 'gen1'], // <-- some are different, length 3 - sampleLastNGenerations: 3, - }); - - expect(result).toBe(false); - }); - - it('returns true when all *sampled* generations are the same as the current generation, and there are older samples past the last N', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: [ - 'gen2', // <-- older sample will be ignored - 'gen1', - 'gen1', - 'gen1', - ], - sampleLastNGenerations: 3, - }); - - expect(result).toBe(true); - }); - - it('returns false when some of the *sampled* generations are NOT the same as the current generation, and there are additional samples past the last N', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: [ - 'gen1', // <-- older sample will be ignored - 'gen1', - 'gen1', - 'gen2', - ], - sampleLastNGenerations: 3, - }); - - expect(result).toBe(false); - }); - - it('returns false when sampling fewer generations than sampleLastNGenerations, and all are the same as the current generation', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: ['gen1', 'gen1'], // <-- same, but only 2 generations - sampleLastNGenerations: 3, - }); - - expect(result).toBe(false); - }); - - it('returns false when sampling fewer generations than sampleLastNGenerations, and some are different from the current generation', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: ['gen1', 'gen2'], // <-- different, but only 2 generations - sampleLastNGenerations: 3, - }); - - expect(result).toBe(false); - }); - - it('returns false when there are no previous generations to sample', () => { - const result = generationsAreRepeating({ - currentGeneration: 'gen1', - previousGenerations: [], - sampleLastNGenerations: 3, - }); - - expect(result).toBe(false); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx deleted file mode 100644 index 6cc9cd86c9d2f..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** Returns true if the last n generations are repeating the same output */ -export const generationsAreRepeating = ({ - currentGeneration, - previousGenerations, - sampleLastNGenerations, -}: { - currentGeneration: string; - previousGenerations: string[]; - sampleLastNGenerations: number; -}): boolean => { - const generationsToSample = previousGenerations.slice(-sampleLastNGenerations); - - if (generationsToSample.length < sampleLastNGenerations) { - return false; // Not enough generations to sample - } - - return generationsToSample.every((generation) => generation === currentGeneration); -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts deleted file mode 100644 index 7eacaad1d7e39..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import { ChatPromptTemplate } from '@langchain/core/prompts'; -import { Runnable } from '@langchain/core/runnables'; - -import { getOutputParser } from '../get_output_parser'; - -interface GetChainWithFormatInstructions { - chain: Runnable; - formatInstructions: string; - llmType: string; -} - -export const getChainWithFormatInstructions = ( - llm: ActionsClientLlm -): GetChainWithFormatInstructions => { - const outputParser = getOutputParser(); - const formatInstructions = outputParser.getFormatInstructions(); - - const prompt = ChatPromptTemplate.fromTemplate( - `Answer the user's question as best you can:\n{format_instructions}\n{query}` - ); - - const chain = prompt.pipe(llm); - const llmType = llm._llmType(); - - return { chain, formatInstructions, llmType }; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts deleted file mode 100644 index 10b5c323891a1..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getCombined = ({ - combinedGenerations, - partialResponse, -}: { - combinedGenerations: string; - partialResponse: string; -}): string => `${combinedGenerations}${partialResponse}`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts deleted file mode 100644 index 4c9ac71f8310c..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEmpty } from 'lodash/fp'; - -import { getAlertsContextPrompt } from '../../generate/helpers/get_alerts_context_prompt'; -import { getContinuePrompt } from '../get_continue_prompt'; - -/** - * Returns the the initial query, or the initial query combined with a - * continuation prompt and partial results - */ -export const getCombinedAttackDiscoveryPrompt = ({ - anonymizedAlerts, - attackDiscoveryPrompt, - combinedMaybePartialResults, -}: { - anonymizedAlerts: string[]; - attackDiscoveryPrompt: string; - /** combined results that may contain incomplete JSON */ - combinedMaybePartialResults: string; -}): string => { - const alertsContextPrompt = getAlertsContextPrompt({ - anonymizedAlerts, - attackDiscoveryPrompt, - }); - - return isEmpty(combinedMaybePartialResults) - ? alertsContextPrompt // no partial results yet - : `${alertsContextPrompt} - -${getContinuePrompt()} - -""" -${combinedMaybePartialResults} -""" - -`; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts deleted file mode 100644 index 628ba0531332c..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getContinuePrompt = - (): string => `Continue exactly where you left off in the JSON output below, generating only the additional JSON output when it's required to complete your work. The additional JSON output MUST ALWAYS follow these rules: -1) it MUST conform to the schema above, because it will be checked against the JSON schema -2) it MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds), because it will be parsed as JSON -3) it MUST NOT repeat any the previous output, because that would prevent partial results from being combined -4) it MUST NOT restart from the beginning, because that would prevent partial results from being combined -5) it MUST NOT be prefixed or suffixed with additional text outside of the JSON, because that would prevent it from being combined and parsed as JSON: -`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts deleted file mode 100644 index 25bace13d40c8..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getDefaultAttackDiscoveryPrompt = (): string => - "You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds)."; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts deleted file mode 100644 index 569c8cf4e04a5..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { getOutputParser } from '.'; - -describe('getOutputParser', () => { - it('returns a structured output parser with the expected format instructions', () => { - const outputParser = getOutputParser(); - - const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. - -\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. - -For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} -would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. -Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. - -Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! - -Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: -\`\`\`json -{"type":"object","properties":{"insights":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"}},\"required\":[\"insights\"],\"additionalProperties":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"} -\`\`\` -`; - - expect(outputParser.getFormatInstructions()).toEqual(expected); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts deleted file mode 100644 index 2ca0d72b63eb4..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { StructuredOutputParser } from 'langchain/output_parsers'; - -import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; - -export const getOutputParser = () => - StructuredOutputParser.fromZodSchema(AttackDiscoveriesGenerationSchema); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts deleted file mode 100644 index 3f7a0a9d802b3..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; -import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; - -import { addTrailingBackticksIfNecessary } from '../add_trailing_backticks_if_necessary'; -import { extractJson } from '../extract_json'; -import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; - -export const parseCombinedOrThrow = ({ - combinedResponse, - generationAttempts, - llmType, - logger, - nodeName, -}: { - /** combined responses that maybe valid JSON */ - combinedResponse: string; - generationAttempts: number; - nodeName: string; - llmType: string; - logger?: Logger; -}): AttackDiscovery[] => { - const timestamp = new Date().toISOString(); - - const extractedJson = extractJson(addTrailingBackticksIfNecessary(combinedResponse)); - - logger?.debug( - () => - `${nodeName} node is parsing extractedJson (${llmType}) from attempt ${generationAttempts}` - ); - - const unvalidatedParsed = JSON.parse(extractedJson); - - logger?.debug( - () => - `${nodeName} node is validating combined response (${llmType}) from attempt ${generationAttempts}` - ); - - const validatedResponse = AttackDiscoveriesGenerationSchema.parse(unvalidatedParsed); - - logger?.debug( - () => - `${nodeName} node successfully validated Attack discoveries response (${llmType}) from attempt ${generationAttempts}` - ); - - return [...validatedResponse.insights.map((insight) => ({ ...insight, timestamp }))]; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts deleted file mode 100644 index f938f6436db98..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const responseIsHallucinated = (result: string): boolean => - result.includes('{{ host.name hostNameValue }}'); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts deleted file mode 100644 index e642e598e73f0..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { GraphState } from '../../../../types'; - -export const discardPreviousRefinements = ({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected, - state, -}: { - generationAttempts: number; - hallucinationFailures: number; - isHallucinationDetected: boolean; - state: GraphState; -}): GraphState => { - return { - ...state, - combinedRefinements: '', // <-- reset the combined refinements - generationAttempts: generationAttempts + 1, - refinements: [], // <-- reset the refinements - hallucinationFailures: isHallucinationDetected - ? hallucinationFailures + 1 - : hallucinationFailures, - }; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts deleted file mode 100644 index 11ea40a48ae55..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; -import { isEmpty } from 'lodash/fp'; - -import { getContinuePrompt } from '../../../helpers/get_continue_prompt'; - -/** - * Returns a prompt that combines the initial query, a refine prompt, and partial results - */ -export const getCombinedRefinePrompt = ({ - attackDiscoveryPrompt, - combinedRefinements, - refinePrompt, - unrefinedResults, -}: { - attackDiscoveryPrompt: string; - combinedRefinements: string; - refinePrompt: string; - unrefinedResults: AttackDiscovery[] | null; -}): string => { - const baseQuery = `${attackDiscoveryPrompt} - -${refinePrompt} - -""" -${JSON.stringify(unrefinedResults, null, 2)} -""" - -`; - - return isEmpty(combinedRefinements) - ? baseQuery // no partial results yet - : `${baseQuery} - -${getContinuePrompt()} - -""" -${combinedRefinements} -""" - -`; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts deleted file mode 100644 index 5743316669785..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getDefaultRefinePrompt = - (): string => `You previously generated the following insights, but sometimes they represent the same attack. - -Combine the insights below, when they represent the same attack; leave any insights that are not combined unchanged:`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts deleted file mode 100644 index 13d0a2228a3ee..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * Note: the conditions tested here are different than the generate node - */ -export const getUseUnrefinedResults = ({ - maxHallucinationFailuresReached, - maxRetriesReached, -}: { - maxHallucinationFailuresReached: boolean; - maxRetriesReached: boolean; -}): boolean => maxRetriesReached || maxHallucinationFailuresReached; // we may have reached max halucination failures, but we still want to use the unrefined results diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts deleted file mode 100644 index 0c7987eef92bc..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import type { Logger } from '@kbn/core/server'; - -import { discardPreviousRefinements } from './helpers/discard_previous_refinements'; -import { extractJson } from '../helpers/extract_json'; -import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; -import { getCombined } from '../helpers/get_combined'; -import { getCombinedRefinePrompt } from './helpers/get_combined_refine_prompt'; -import { generationsAreRepeating } from '../helpers/generations_are_repeating'; -import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; -import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; -import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; -import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; -import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; -import type { GraphState } from '../../types'; - -export const getRefineNode = ({ - llm, - logger, -}: { - llm: ActionsClientLlm; - logger?: Logger; -}): ((state: GraphState) => Promise) => { - const refine = async (state: GraphState): Promise => { - logger?.debug(() => '---REFINE---'); - - const { - attackDiscoveryPrompt, - combinedRefinements, - generationAttempts, - hallucinationFailures, - maxGenerationAttempts, - maxHallucinationFailures, - maxRepeatedGenerations, - refinements, - refinePrompt, - unrefinedResults, - } = state; - - let combinedResponse = ''; // mutable, because it must be accessed in the catch block - let partialResponse = ''; // mutable, because it must be accessed in the catch block - - try { - const query = getCombinedRefinePrompt({ - attackDiscoveryPrompt, - combinedRefinements, - refinePrompt, - unrefinedResults, - }); - - const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); - - logger?.debug( - () => `refine node is invoking the chain (${llmType}), attempt ${generationAttempts}` - ); - - const rawResponse = (await chain.invoke({ - format_instructions: formatInstructions, - query, - })) as unknown as string; - - // LOCAL MUTATION: - partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` - - // if the response is hallucinated, discard it: - if (responseIsHallucinated(partialResponse)) { - logger?.debug( - () => - `refine node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated refinements and starting over` - ); - - return discardPreviousRefinements({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected: true, - state, - }); - } - - // if the refinements are repeating, discard previous refinements and start over: - if ( - generationsAreRepeating({ - currentGeneration: partialResponse, - previousGenerations: refinements, - sampleLastNGenerations: maxRepeatedGenerations, - }) - ) { - logger?.debug( - () => - `refine node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` - ); - - // discard the accumulated results and start over: - return discardPreviousRefinements({ - generationAttempts, - hallucinationFailures, - isHallucinationDetected: false, - state, - }); - } - - // LOCAL MUTATION: - combinedResponse = getCombined({ combinedGenerations: combinedRefinements, partialResponse }); // combine the new response with the previous ones - - const attackDiscoveries = parseCombinedOrThrow({ - combinedResponse, - generationAttempts, - llmType, - logger, - nodeName: 'refine', - }); - - return { - ...state, - attackDiscoveries, // the final, refined answer - generationAttempts: generationAttempts + 1, - combinedRefinements: combinedResponse, - refinements: [...refinements, partialResponse], - }; - } catch (error) { - const parsingError = `refine node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; - logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response - - const maxRetriesReached = getMaxRetriesReached({ - generationAttempts: generationAttempts + 1, - maxGenerationAttempts, - }); - - const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ - hallucinationFailures, - maxHallucinationFailures, - }); - - // we will use the unrefined results if we have reached the maximum number of retries or hallucination failures: - const useUnrefinedResults = getUseUnrefinedResults({ - maxHallucinationFailuresReached, - maxRetriesReached, - }); - - if (useUnrefinedResults) { - logger?.debug( - () => - `refine node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` - ); - } - - return { - ...state, - attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, - combinedRefinements: combinedResponse, - errors: [...state.errors, parsingError], - generationAttempts: generationAttempts + 1, - refinements: [...refinements, partialResponse], - }; - } - }; - - return refine; -}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts deleted file mode 100644 index 3a8b7ed3a6b94..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient } from '@kbn/core/server'; -import { Replacements } from '@kbn/elastic-assistant-common'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; -import type { Document } from '@langchain/core/documents'; -import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; - -import { getAnonymizedAlerts } from '../helpers/get_anonymized_alerts'; - -export type CustomRetrieverInput = BaseRetrieverInput; - -export class AnonymizedAlertsRetriever extends BaseRetriever { - lc_namespace = ['langchain', 'retrievers']; - - #alertsIndexPattern?: string; - #anonymizationFields?: AnonymizationFieldResponse[]; - #esClient: ElasticsearchClient; - #onNewReplacements?: (newReplacements: Replacements) => void; - #replacements?: Replacements; - #size?: number; - - constructor({ - alertsIndexPattern, - anonymizationFields, - fields, - esClient, - onNewReplacements, - replacements, - size, - }: { - alertsIndexPattern?: string; - anonymizationFields?: AnonymizationFieldResponse[]; - fields?: CustomRetrieverInput; - esClient: ElasticsearchClient; - onNewReplacements?: (newReplacements: Replacements) => void; - replacements?: Replacements; - size?: number; - }) { - super(fields); - - this.#alertsIndexPattern = alertsIndexPattern; - this.#anonymizationFields = anonymizationFields; - this.#esClient = esClient; - this.#onNewReplacements = onNewReplacements; - this.#replacements = replacements; - this.#size = size; - } - - async _getRelevantDocuments( - query: string, - runManager?: CallbackManagerForRetrieverRun - ): Promise { - const anonymizedAlerts = await getAnonymizedAlerts({ - alertsIndexPattern: this.#alertsIndexPattern, - anonymizationFields: this.#anonymizationFields, - esClient: this.#esClient, - onNewReplacements: this.#onNewReplacements, - replacements: this.#replacements, - size: this.#size, - }); - - return anonymizedAlerts.map((alert) => ({ - pageContent: alert, - metadata: {}, - })); - } -} diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts deleted file mode 100644 index 951ae3bca8854..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import { Replacements } from '@kbn/elastic-assistant-common'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; - -import { AnonymizedAlertsRetriever } from './anonymized_alerts_retriever'; -import type { GraphState } from '../../types'; - -export const getRetrieveAnonymizedAlertsNode = ({ - alertsIndexPattern, - anonymizationFields, - esClient, - logger, - onNewReplacements, - replacements, - size, -}: { - alertsIndexPattern?: string; - anonymizationFields?: AnonymizationFieldResponse[]; - esClient: ElasticsearchClient; - logger?: Logger; - onNewReplacements?: (replacements: Replacements) => void; - replacements?: Replacements; - size?: number; -}): ((state: GraphState) => Promise) => { - let localReplacements = { ...(replacements ?? {}) }; - const localOnNewReplacements = (newReplacements: Replacements) => { - localReplacements = { ...localReplacements, ...newReplacements }; - - onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements - }; - - const retriever = new AnonymizedAlertsRetriever({ - alertsIndexPattern, - anonymizationFields, - esClient, - onNewReplacements: localOnNewReplacements, - replacements, - size, - }); - - const retrieveAnonymizedAlerts = async (state: GraphState): Promise => { - logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS---'); - const documents = await retriever - .withConfig({ runName: 'runAnonymizedAlertsRetriever' }) - .invoke(''); - - return { - ...state, - anonymizedAlerts: documents, - replacements: localReplacements, - }; - }; - - return retrieveAnonymizedAlerts; -}; - -/** - * Retrieve documents - * - * @param {GraphState} state The current state of the graph. - * @param {RunnableConfig | undefined} config The configuration object for tracing. - * @returns {Promise} The new state object. - */ diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts deleted file mode 100644 index 4229155cc2e25..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import type { Document } from '@langchain/core/documents'; -import type { StateGraphArgs } from '@langchain/langgraph'; - -import { - DEFAULT_MAX_GENERATION_ATTEMPTS, - DEFAULT_MAX_HALLUCINATION_FAILURES, - DEFAULT_MAX_REPEATED_GENERATIONS, -} from '../constants'; -import { getDefaultAttackDiscoveryPrompt } from '../nodes/helpers/get_default_attack_discovery_prompt'; -import { getDefaultRefinePrompt } from '../nodes/refine/helpers/get_default_refine_prompt'; -import type { GraphState } from '../types'; - -export const getDefaultGraphState = (): StateGraphArgs['channels'] => ({ - attackDiscoveries: { - value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, - default: () => null, - }, - attackDiscoveryPrompt: { - value: (x: string, y?: string) => y ?? x, - default: () => getDefaultAttackDiscoveryPrompt(), - }, - anonymizedAlerts: { - value: (x: Document[], y?: Document[]) => y ?? x, - default: () => [], - }, - combinedGenerations: { - value: (x: string, y?: string) => y ?? x, - default: () => '', - }, - combinedRefinements: { - value: (x: string, y?: string) => y ?? x, - default: () => '', - }, - errors: { - value: (x: string[], y?: string[]) => y ?? x, - default: () => [], - }, - generationAttempts: { - value: (x: number, y?: number) => y ?? x, - default: () => 0, - }, - generations: { - value: (x: string[], y?: string[]) => y ?? x, - default: () => [], - }, - hallucinationFailures: { - value: (x: number, y?: number) => y ?? x, - default: () => 0, - }, - refinePrompt: { - value: (x: string, y?: string) => y ?? x, - default: () => getDefaultRefinePrompt(), - }, - maxGenerationAttempts: { - value: (x: number, y?: number) => y ?? x, - default: () => DEFAULT_MAX_GENERATION_ATTEMPTS, - }, - maxHallucinationFailures: { - value: (x: number, y?: number) => y ?? x, - default: () => DEFAULT_MAX_HALLUCINATION_FAILURES, - }, - maxRepeatedGenerations: { - value: (x: number, y?: number) => y ?? x, - default: () => DEFAULT_MAX_REPEATED_GENERATIONS, - }, - refinements: { - value: (x: string[], y?: string[]) => y ?? x, - default: () => [], - }, - replacements: { - value: (x: Replacements, y?: Replacements) => y ?? x, - default: () => ({}), - }, - unrefinedResults: { - value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, - default: () => null, - }, -}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts deleted file mode 100644 index b4473a02b82ae..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import type { Document } from '@langchain/core/documents'; - -export interface GraphState { - attackDiscoveries: AttackDiscovery[] | null; - attackDiscoveryPrompt: string; - anonymizedAlerts: Document[]; - combinedGenerations: string; - combinedRefinements: string; - errors: string[]; - generationAttempts: number; - generations: string[]; - hallucinationFailures: number; - maxGenerationAttempts: number; - maxHallucinationFailures: number; - maxRepeatedGenerations: number; - refinements: string[]; - refinePrompt: string; - replacements: Replacements; - unrefinedResults: AttackDiscovery[] | null; -} diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts index b9e4f85a800a0..706da7197f31a 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts @@ -10,41 +10,14 @@ import { GetDefaultAssistantGraphParams, DefaultAssistantGraph, } from './default_assistant_graph/graph'; -import { - DefaultAttackDiscoveryGraph, - GetDefaultAttackDiscoveryGraphParams, - getDefaultAttackDiscoveryGraph, -} from '../../attack_discovery/graphs/default_attack_discovery_graph'; export type GetAssistantGraph = (params: GetDefaultAssistantGraphParams) => DefaultAssistantGraph; -export type GetAttackDiscoveryGraph = ( - params: GetDefaultAttackDiscoveryGraphParams -) => DefaultAttackDiscoveryGraph; - -export type GraphType = 'assistant' | 'attack-discovery'; - -export interface AssistantGraphMetadata { - getDefaultAssistantGraph: GetAssistantGraph; - graphType: 'assistant'; -} - -export interface AttackDiscoveryGraphMetadata { - getDefaultAttackDiscoveryGraph: GetAttackDiscoveryGraph; - graphType: 'attack-discovery'; -} - -export type GraphMetadata = AssistantGraphMetadata | AttackDiscoveryGraphMetadata; /** * Map of the different Assistant Graphs. Useful for running evaluations. */ -export const ASSISTANT_GRAPH_MAP: Record = { - DefaultAssistantGraph: { - getDefaultAssistantGraph, - graphType: 'assistant', - }, - DefaultAttackDiscoveryGraph: { - getDefaultAttackDiscoveryGraph, - graphType: 'attack-discovery', - }, +export const ASSISTANT_GRAPH_MAP: Record = { + DefaultAssistantGraph: getDefaultAssistantGraph, + // TODO: Support additional graphs + // AttackDiscoveryGraph: getDefaultAssistantGraph, }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts similarity index 80% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts index 9f5efbe5041d5..66aca77f1eb8b 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts @@ -8,23 +8,15 @@ import { cancelAttackDiscoveryRoute } from './cancel_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../../../__mocks__/server'; -import { requestContextMock } from '../../../../__mocks__/request_context'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../../../lib/attack_discovery/persistence'; -import { transformESSearchToAttackDiscovery } from '../../../../lib/attack_discovery/persistence/transforms/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; -import { getCancelAttackDiscoveryRequest } from '../../../../__mocks__/request'; -import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; - -jest.mock('../../helpers/helpers', () => { - const original = jest.requireActual('../../helpers/helpers'); - - return { - ...original, - updateAttackDiscoveryStatusToCanceled: jest.fn(), - }; -}); +import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getCancelAttackDiscoveryRequest } from '../../__mocks__/request'; +import { updateAttackDiscoveryStatusToCanceled } from './helpers'; +jest.mock('./helpers'); const { clients, context } = requestContextMock.createTools(); const server: ReturnType = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts similarity index 91% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts index 86631708b1cf7..47b748c9c432a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts @@ -14,16 +14,16 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; -import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../../../common/constants'; -import { buildResponse } from '../../../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../../../types'; +import { updateAttackDiscoveryStatusToCanceled } from './helpers'; +import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../common/constants'; +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; export const cancelAttackDiscoveryRoute = ( router: IRouter ) => { router.versioned - .post({ + .put({ access: 'internal', path: ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID, options: { diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts similarity index 85% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts index ce07d66b9606e..74cf160c43ffe 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts @@ -8,24 +8,15 @@ import { getAttackDiscoveryRoute } from './get_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../../__mocks__/server'; -import { requestContextMock } from '../../../__mocks__/request_context'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; -import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; -import { getAttackDiscoveryRequest } from '../../../__mocks__/request'; -import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from '../helpers/helpers'; - -jest.mock('../helpers/helpers', () => { - const original = jest.requireActual('../helpers/helpers'); - - return { - ...original, - getAttackDiscoveryStats: jest.fn(), - updateAttackDiscoveryLastViewedAt: jest.fn(), - }; -}); +import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoveryRequest } from '../../__mocks__/request'; +import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from './helpers'; +jest.mock('./helpers'); const mockStats = { newConnectorResultsCount: 2, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts index e3756b10a3fb3..09b2df98fe090 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts @@ -14,10 +14,10 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from '../helpers/helpers'; -import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../../common/constants'; -import { buildResponse } from '../../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../../types'; +import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from './helpers'; +import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../common/constants'; +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; export const getAttackDiscoveryRoute = (router: IRouter) => { router.versioned diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts new file mode 100644 index 0000000000000..d5eaf7d159618 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts @@ -0,0 +1,805 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AuthenticatedUser } from '@kbn/core-security-common'; +import moment from 'moment'; +import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; + +import { + REQUIRED_FOR_ATTACK_DISCOVERY, + addGenerationInterval, + attackDiscoveryStatus, + getAssistantToolParams, + handleToolError, + updateAttackDiscoveryStatusToCanceled, + updateAttackDiscoveryStatusToRunning, + updateAttackDiscoveries, + getAttackDiscoveryStats, +} from './helpers'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { KibanaRequest } from '@kbn/core-http-server'; +import { + AttackDiscoveryPostRequestBody, + ExecuteConnectorRequestBody, +} from '@kbn/elastic-assistant-common'; +import { coreMock } from '@kbn/core/server/mocks'; +import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; + +import { + getAnonymizationFieldMock, + getUpdateAnonymizationFieldSchemaMock, +} from '../../__mocks__/anonymization_fields_schema.mock'; + +jest.mock('lodash/fp', () => ({ + uniq: jest.fn((arr) => Array.from(new Set(arr))), +})); + +jest.mock('@kbn/securitysolution-es-utils', () => ({ + transformError: jest.fn((err) => err), +})); +jest.mock('@kbn/langchain/server', () => ({ + ActionsClientLlm: jest.fn(), +})); +jest.mock('../evaluate/utils', () => ({ + getLangSmithTracer: jest.fn().mockReturnValue([]), +})); +jest.mock('../utils', () => ({ + getLlmType: jest.fn().mockReturnValue('llm-type'), +})); +const findAttackDiscoveryByConnectorId = jest.fn(); +const updateAttackDiscovery = jest.fn(); +const createAttackDiscovery = jest.fn(); +const getAttackDiscovery = jest.fn(); +const findAllAttackDiscoveries = jest.fn(); +const mockDataClient = { + findAttackDiscoveryByConnectorId, + updateAttackDiscovery, + createAttackDiscovery, + getAttackDiscovery, + findAllAttackDiscoveries, +} as unknown as AttackDiscoveryDataClient; +const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); +const mockLogger = loggerMock.create(); +const mockTelemetry = coreMock.createSetup().analytics; +const mockError = new Error('Test error'); + +const mockAuthenticatedUser = { + username: 'user', + profile_uid: '1234', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; + +const mockApiConfig = { + connectorId: 'connector-id', + actionTypeId: '.bedrock', + model: 'model', + provider: OpenAiProviderType.OpenAi, +}; + +const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockRequest: KibanaRequest = {} as unknown as KibanaRequest< + unknown, + unknown, + any, // eslint-disable-line @typescript-eslint/no-explicit-any + any // eslint-disable-line @typescript-eslint/no-explicit-any +>; + +describe('helpers', () => { + const date = '2024-03-28T22:27:28.000Z'; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { + jest.clearAllMocks(); + jest.setSystemTime(new Date(date)); + getAttackDiscovery.mockResolvedValue(mockCurrentAd); + updateAttackDiscovery.mockResolvedValue({}); + }); + describe('getAssistantToolParams', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const esClient = elasticsearchClientMock.createElasticsearchClient(); + const actionsClient = actionsClientMock.create(); + const langChainTimeout = 1000; + const latestReplacements = {}; + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: 'test-connecter-id', + llmType: 'bedrock', + logger: mockLogger, + temperature: 0, + timeout: 580000, + }); + const onNewReplacements = jest.fn(); + const size = 20; + + const mockParams = { + actionsClient, + alertsIndexPattern: 'alerts-*', + anonymizationFields: [{ id: '1', field: 'field1', allowed: true, anonymized: true }], + apiConfig: mockApiConfig, + esClient: mockEsClient, + connectorTimeout: 1000, + langChainTimeout: 2000, + langSmithProject: 'project', + langSmithApiKey: 'api-key', + logger: mockLogger, + latestReplacements: {}, + onNewReplacements: jest.fn(), + request: {} as KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + >, + size: 10, + }; + + it('should return formatted assistant tool params', () => { + const result = getAssistantToolParams(mockParams); + + expect(ActionsClientLlm).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'connector-id', + llmType: 'llm-type', + }) + ); + expect(result.anonymizationFields).toEqual([ + ...mockParams.anonymizationFields, + ...REQUIRED_FOR_ATTACK_DISCOVERY, + ]); + }); + + it('returns the expected AssistantToolParams when anonymizationFields are provided', () => { + const anonymizationFields = [ + getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()), + ]; + + const result = getAssistantToolParams({ + actionsClient, + alertsIndexPattern, + apiConfig: mockApiConfig, + anonymizationFields, + connectorTimeout: 1000, + latestReplacements, + esClient, + langChainTimeout, + logger: mockLogger, + onNewReplacements, + request: mockRequest, + size, + }); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: [...anonymizationFields, ...REQUIRED_FOR_ATTACK_DISCOVERY], + isEnabledKnowledgeBase: false, + chain: undefined, + esClient, + langChainTimeout, + llm, + logger: mockLogger, + onNewReplacements, + replacements: latestReplacements, + request: mockRequest, + size, + }); + }); + + it('returns the expected AssistantToolParams when anonymizationFields is undefined', () => { + const anonymizationFields = undefined; + + const result = getAssistantToolParams({ + actionsClient, + alertsIndexPattern, + apiConfig: mockApiConfig, + anonymizationFields, + connectorTimeout: 1000, + latestReplacements, + esClient, + langChainTimeout, + logger: mockLogger, + onNewReplacements, + request: mockRequest, + size, + }); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: [...REQUIRED_FOR_ATTACK_DISCOVERY], + isEnabledKnowledgeBase: false, + chain: undefined, + esClient, + langChainTimeout, + llm, + logger: mockLogger, + onNewReplacements, + replacements: latestReplacements, + request: mockRequest, + size, + }); + }); + + describe('addGenerationInterval', () => { + const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 }; + const existingIntervals = [ + { date: '2024-01-02T00:00:00Z', durationMs: 2000 }, + { date: '2024-01-03T00:00:00Z', durationMs: 3000 }, + ]; + + it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => { + const result = addGenerationInterval(existingIntervals, generationInterval); + expect(result.length).toBeLessThanOrEqual(5); + expect(result).toContain(generationInterval); + }); + + it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => { + const longExistingIntervals = [...Array(5)].map((_, i) => ({ + date: `2024-01-0${i + 2}T00:00:00Z`, + durationMs: (i + 2) * 1000, + })); + const result = addGenerationInterval(longExistingIntervals, generationInterval); + expect(result.length).toBe(5); + expect(result).not.toContain(longExistingIntervals[4]); + }); + }); + + describe('updateAttackDiscoveryStatusToRunning', () => { + it('should update existing attack discovery to running', async () => { + const existingAd = { id: 'existing-id', backingIndex: 'index' }; + findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); + updateAttackDiscovery.mockResolvedValue(existingAd); + + const result = await updateAttackDiscoveryStatusToRunning( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig + ); + + expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ + connectorId: mockApiConfig.connectorId, + authenticatedUser: mockAuthenticatedUser, + }); + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: expect.objectContaining({ + status: attackDiscoveryStatus.running, + }), + authenticatedUser: mockAuthenticatedUser, + }); + expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd }); + }); + + it('should create a new attack discovery if none exists', async () => { + const newAd = { id: 'new-id', backingIndex: 'index' }; + findAttackDiscoveryByConnectorId.mockResolvedValue(null); + createAttackDiscovery.mockResolvedValue(newAd); + + const result = await updateAttackDiscoveryStatusToRunning( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig + ); + + expect(createAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryCreate: expect.objectContaining({ + status: attackDiscoveryStatus.running, + }), + authenticatedUser: mockAuthenticatedUser, + }); + expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd }); + }); + + it('should throw an error if updating or creating attack discovery fails', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue(null); + createAttackDiscovery.mockResolvedValue(null); + + await expect( + updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig) + ).rejects.toThrow('Could not create attack discovery for connectorId: connector-id'); + }); + }); + + describe('updateAttackDiscoveryStatusToCanceled', () => { + const existingAd = { + id: 'existing-id', + backingIndex: 'index', + status: attackDiscoveryStatus.running, + }; + it('should update existing attack discovery to canceled', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); + updateAttackDiscovery.mockResolvedValue(existingAd); + + const result = await updateAttackDiscoveryStatusToCanceled( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig.connectorId + ); + + expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ + connectorId: mockApiConfig.connectorId, + authenticatedUser: mockAuthenticatedUser, + }); + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: expect.objectContaining({ + status: attackDiscoveryStatus.canceled, + }), + authenticatedUser: mockAuthenticatedUser, + }); + expect(result).toEqual(existingAd); + }); + + it('should throw an error if attack discovery is not running', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue({ + ...existingAd, + status: attackDiscoveryStatus.succeeded, + }); + await expect( + updateAttackDiscoveryStatusToCanceled( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig.connectorId + ) + ).rejects.toThrow( + 'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.' + ); + }); + + it('should throw an error if attack discovery does not exist', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue(null); + await expect( + updateAttackDiscoveryStatusToCanceled( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig.connectorId + ) + ).rejects.toThrow('Could not find attack discovery for connector id: connector-id'); + }); + it('should throw error if updateAttackDiscovery returns null', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); + updateAttackDiscovery.mockResolvedValue(null); + + await expect( + updateAttackDiscoveryStatusToCanceled( + mockDataClient, + mockAuthenticatedUser, + mockApiConfig.connectorId + ) + ).rejects.toThrow('Could not update attack discovery for connector id: connector-id'); + }); + }); + + describe('updateAttackDiscoveries', () => { + const mockAttackDiscoveryId = 'attack-discovery-id'; + const mockLatestReplacements = {}; + const mockRawAttackDiscoveries = JSON.stringify({ + alertsContextCount: 5, + attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], + }); + const mockSize = 10; + const mockStartTime = moment('2024-03-28T22:25:28.000Z'); + + const mockArgs = { + apiConfig: mockApiConfig, + attackDiscoveryId: mockAttackDiscoveryId, + authenticatedUser: mockAuthenticatedUser, + dataClient: mockDataClient, + latestReplacements: mockLatestReplacements, + logger: mockLogger, + rawAttackDiscoveries: mockRawAttackDiscoveries, + size: mockSize, + startTime: mockStartTime, + telemetry: mockTelemetry, + }; + + it('should update attack discoveries and report success telemetry', async () => { + await updateAttackDiscoveries(mockArgs); + + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: { + alertsContextCount: 5, + attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], + status: attackDiscoveryStatus.succeeded, + id: mockAttackDiscoveryId, + replacements: mockLatestReplacements, + backingIndex: mockCurrentAd.backingIndex, + generationIntervals: [ + { date, durationMs: 120000 }, + ...mockCurrentAd.generationIntervals, + ], + }, + authenticatedUser: mockAuthenticatedUser, + }); + + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { + actionTypeId: mockApiConfig.actionTypeId, + alertsContextCount: 5, + alertsCount: 3, + configuredAlertsCount: mockSize, + discoveriesGenerated: 2, + durationMs: 120000, + model: mockApiConfig.model, + provider: mockApiConfig.provider, + }); + }); + + it('should update attack discoveries without generation interval if no discoveries are found', async () => { + const noDiscoveriesRaw = JSON.stringify({ + alertsContextCount: 0, + attackDiscoveries: [], + }); + + await updateAttackDiscoveries({ + ...mockArgs, + rawAttackDiscoveries: noDiscoveriesRaw, + }); + + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: { + alertsContextCount: 0, + attackDiscoveries: [], + status: attackDiscoveryStatus.succeeded, + id: mockAttackDiscoveryId, + replacements: mockLatestReplacements, + backingIndex: mockCurrentAd.backingIndex, + }, + authenticatedUser: mockAuthenticatedUser, + }); + + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { + actionTypeId: mockApiConfig.actionTypeId, + alertsContextCount: 0, + alertsCount: 0, + configuredAlertsCount: mockSize, + discoveriesGenerated: 0, + durationMs: 120000, + model: mockApiConfig.model, + provider: mockApiConfig.provider, + }); + }); + + it('should catch and log an error if raw attack discoveries is null', async () => { + await updateAttackDiscoveries({ + ...mockArgs, + rawAttackDiscoveries: null, + }); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { + actionTypeId: mockArgs.apiConfig.actionTypeId, + errorMessage: 'tool returned no attack discoveries', + model: mockArgs.apiConfig.model, + provider: mockArgs.apiConfig.provider, + }); + }); + + it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { + getAttackDiscovery.mockResolvedValue({ + ...mockCurrentAd, + status: attackDiscoveryStatus.canceled, + }); + await updateAttackDiscoveries(mockArgs); + + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(updateAttackDiscovery).not.toHaveBeenCalled(); + }); + + it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { + getAttackDiscovery.mockRejectedValue(mockError); + await updateAttackDiscoveries(mockArgs); + + expect(mockLogger.error).toHaveBeenCalledWith(mockError); + expect(updateAttackDiscovery).not.toHaveBeenCalled(); + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { + actionTypeId: mockArgs.apiConfig.actionTypeId, + errorMessage: mockError.message, + model: mockArgs.apiConfig.model, + provider: mockArgs.apiConfig.provider, + }); + }); + }); + + describe('handleToolError', () => { + const mockArgs = { + apiConfig: mockApiConfig, + attackDiscoveryId: 'discovery-id', + authenticatedUser: mockAuthenticatedUser, + backingIndex: 'backing-index', + dataClient: mockDataClient, + err: mockError, + latestReplacements: {}, + logger: mockLogger, + telemetry: mockTelemetry, + }; + + it('should log the error and update attack discovery status to failed', async () => { + await handleToolError(mockArgs); + + expect(mockLogger.error).toHaveBeenCalledWith(mockError); + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: { + status: attackDiscoveryStatus.failed, + attackDiscoveries: [], + backingIndex: 'foo', + failureReason: 'Test error', + id: 'discovery-id', + replacements: {}, + }, + authenticatedUser: mockArgs.authenticatedUser, + }); + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { + actionTypeId: mockArgs.apiConfig.actionTypeId, + errorMessage: mockError.message, + model: mockArgs.apiConfig.model, + provider: mockArgs.apiConfig.provider, + }); + }); + + it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => { + updateAttackDiscovery.mockRejectedValue(mockError); + await handleToolError(mockArgs); + + expect(mockLogger.error).toHaveBeenCalledWith(mockError); + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: { + status: attackDiscoveryStatus.failed, + attackDiscoveries: [], + backingIndex: 'foo', + failureReason: 'Test error', + id: 'discovery-id', + replacements: {}, + }, + authenticatedUser: mockArgs.authenticatedUser, + }); + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { + actionTypeId: mockArgs.apiConfig.actionTypeId, + errorMessage: mockError.message, + model: mockArgs.apiConfig.model, + provider: mockArgs.apiConfig.provider, + }); + }); + + it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { + getAttackDiscovery.mockResolvedValue({ + ...mockCurrentAd, + status: attackDiscoveryStatus.canceled, + }); + await handleToolError(mockArgs); + + expect(mockTelemetry.reportEvent).not.toHaveBeenCalled(); + expect(updateAttackDiscovery).not.toHaveBeenCalled(); + }); + + it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { + getAttackDiscovery.mockRejectedValue(mockError); + await handleToolError(mockArgs); + + expect(mockLogger.error).toHaveBeenCalledWith(mockError); + expect(updateAttackDiscovery).not.toHaveBeenCalled(); + expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { + actionTypeId: mockArgs.apiConfig.actionTypeId, + errorMessage: mockError.message, + model: mockArgs.apiConfig.model, + provider: mockArgs.apiConfig.provider, + }); + }); + }); + }); + describe('getAttackDiscoveryStats', () => { + const mockDiscoveries = [ + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:47:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:46:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-12T19:54:50.428Z', + id: '745e005b-7248-4c08-b8b6-4cad263b4be0', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T19:54:50.428Z', + updatedAt: '2024-06-17T20:47:27.182Z', + lastViewedAt: '2024-06-17T20:27:27.182Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'running', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gen-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-13T17:50:59.409Z', + id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:50:59.409Z', + updatedAt: '2024-06-17T20:47:59.969Z', + lastViewedAt: '2024-06-17T20:47:35.227Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gpt4o-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T21:18:56.377Z', + id: '82fced1d-de48-42db-9f56-e45122dee017', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T21:18:56.377Z', + updatedAt: '2024-06-17T20:47:39.372Z', + lastViewedAt: '2024-06-17T20:47:39.372Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'canceled', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-bedrock', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T16:44:23.107Z', + id: 'a4709094-6116-484b-b096-1e8d151cb4b7', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T16:44:23.107Z', + updatedAt: '2024-06-17T20:48:16.961Z', + lastViewedAt: '2024-06-17T20:47:16.961Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 0, + apiConfig: { + connectorId: 'my-gen-a2i', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [ + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: 'steph threw an error', + }, + ]; + beforeEach(() => { + findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); + }); + it('returns the formatted stats object', async () => { + const stats = await getAttackDiscoveryStats({ + authenticatedUser: mockAuthenticatedUser, + dataClient: mockDataClient, + }); + expect(stats).toEqual([ + { + hasViewed: true, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: false, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts similarity index 55% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts index 188976f0b3f5c..f016d6ac29118 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts @@ -5,29 +5,38 @@ * 2.0. */ -import { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; +import { AnalyticsServiceSetup, AuthenticatedUser, KibanaRequest, Logger } from '@kbn/core/server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { ApiConfig, AttackDiscovery, + AttackDiscoveryPostRequestBody, AttackDiscoveryResponse, AttackDiscoveryStat, AttackDiscoveryStatus, + ExecuteConnectorRequestBody, GenerationInterval, Replacements, } from '@kbn/elastic-assistant-common'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import type { Document } from '@langchain/core/documents'; import { v4 as uuidv4 } from 'uuid'; +import { ActionsClientLlm } from '@kbn/langchain/server'; + import { Moment } from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; import moment from 'moment/moment'; import { uniq } from 'lodash/fp'; - +import { PublicMethodsOf } from '@kbn/utility-types'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { getLlmType } from '../utils'; +import type { GetRegisteredTools } from '../../services/app_context'; import { ATTACK_DISCOVERY_ERROR_EVENT, ATTACK_DISCOVERY_SUCCESS_EVENT, -} from '../../../lib/telemetry/event_based_telemetry'; -import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +} from '../../lib/telemetry/event_based_telemetry'; +import { AssistantToolParams } from '../../types'; +import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ { @@ -44,6 +53,116 @@ export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ }, ]; +export const getAssistantToolParams = ({ + actionsClient, + alertsIndexPattern, + anonymizationFields, + apiConfig, + esClient, + connectorTimeout, + langChainTimeout, + langSmithProject, + langSmithApiKey, + logger, + latestReplacements, + onNewReplacements, + request, + size, +}: { + actionsClient: PublicMethodsOf; + alertsIndexPattern: string; + anonymizationFields?: AnonymizationFieldResponse[]; + apiConfig: ApiConfig; + esClient: ElasticsearchClient; + connectorTimeout: number; + langChainTimeout: number; + langSmithProject?: string; + langSmithApiKey?: string; + logger: Logger; + latestReplacements: Replacements; + onNewReplacements: (newReplacements: Replacements) => void; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + >; + size: number; +}) => { + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: apiConfig.connectorId, + llmType: getLlmType(apiConfig.actionTypeId), + logger, + temperature: 0, // zero temperature for attack discovery, because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + return formatAssistantToolParams({ + alertsIndexPattern, + anonymizationFields, + esClient, + latestReplacements, + langChainTimeout, + llm, + logger, + onNewReplacements, + request, + size, + }); +}; + +const formatAssistantToolParams = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + langChainTimeout, + latestReplacements, + llm, + logger, + onNewReplacements, + request, + size, +}: { + alertsIndexPattern: string; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + langChainTimeout: number; + latestReplacements: Replacements; + llm: ActionsClientLlm; + logger: Logger; + onNewReplacements: (newReplacements: Replacements) => void; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + >; + size: number; +}): Omit => ({ + alertsIndexPattern, + anonymizationFields: [...(anonymizationFields ?? []), ...REQUIRED_FOR_ATTACK_DISCOVERY], + isEnabledKnowledgeBase: false, // not required for attack discovery + esClient, + langChainTimeout, + llm, + logger, + onNewReplacements, + replacements: latestReplacements, + request, + size, +}); + export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = { canceled: 'canceled', failed: 'failed', @@ -68,8 +187,7 @@ export const addGenerationInterval = ( export const updateAttackDiscoveryStatusToRunning = async ( dataClient: AttackDiscoveryDataClient, authenticatedUser: AuthenticatedUser, - apiConfig: ApiConfig, - alertsContextCount: number + apiConfig: ApiConfig ): Promise<{ currentAd: AttackDiscoveryResponse; attackDiscoveryId: string; @@ -81,7 +199,6 @@ export const updateAttackDiscoveryStatusToRunning = async ( const currentAd = foundAttackDiscovery ? await dataClient?.updateAttackDiscovery({ attackDiscoveryUpdateProps: { - alertsContextCount, backingIndex: foundAttackDiscovery.backingIndex, id: foundAttackDiscovery.id, status: attackDiscoveryStatus.running, @@ -90,7 +207,6 @@ export const updateAttackDiscoveryStatusToRunning = async ( }) : await dataClient?.createAttackDiscovery({ attackDiscoveryCreate: { - alertsContextCount, apiConfig, attackDiscoveries: [], status: attackDiscoveryStatus.running, @@ -145,32 +261,38 @@ export const updateAttackDiscoveryStatusToCanceled = async ( return updatedAttackDiscovery; }; +const getDataFromJSON = (adStringified: string) => { + const { alertsContextCount, attackDiscoveries } = JSON.parse(adStringified); + return { alertsContextCount, attackDiscoveries }; +}; + export const updateAttackDiscoveries = async ({ - anonymizedAlerts, apiConfig, - attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, + rawAttackDiscoveries, size, startTime, telemetry, }: { - anonymizedAlerts: Document[]; apiConfig: ApiConfig; - attackDiscoveries: AttackDiscovery[] | null; attackDiscoveryId: string; authenticatedUser: AuthenticatedUser; dataClient: AttackDiscoveryDataClient; latestReplacements: Replacements; logger: Logger; + rawAttackDiscoveries: string | null; size: number; startTime: Moment; telemetry: AnalyticsServiceSetup; }) => { try { + if (rawAttackDiscoveries == null) { + throw new Error('tool returned no attack discoveries'); + } const currentAd = await dataClient.getAttackDiscovery({ id: attackDiscoveryId, authenticatedUser, @@ -180,12 +302,12 @@ export const updateAttackDiscoveries = async ({ } const endTime = moment(); const durationMs = endTime.diff(startTime); - const alertsContextCount = anonymizedAlerts.length; + const { alertsContextCount, attackDiscoveries } = getDataFromJSON(rawAttackDiscoveries); const updateProps = { alertsContextCount, - attackDiscoveries: attackDiscoveries ?? undefined, + attackDiscoveries, status: attackDiscoveryStatus.succeeded, - ...(alertsContextCount === 0 + ...(alertsContextCount === 0 || attackDiscoveries === 0 ? {} : { generationIntervals: addGenerationInterval(currentAd.generationIntervals, { @@ -205,14 +327,13 @@ export const updateAttackDiscoveries = async ({ telemetry.reportEvent(ATTACK_DISCOVERY_SUCCESS_EVENT.eventType, { actionTypeId: apiConfig.actionTypeId, alertsContextCount: updateProps.alertsContextCount, - alertsCount: - uniq( - updateProps.attackDiscoveries?.flatMap( - (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds - ) - ).length ?? 0, + alertsCount: uniq( + updateProps.attackDiscoveries.flatMap( + (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds + ) + ).length, configuredAlertsCount: size, - discoveriesGenerated: updateProps.attackDiscoveries?.length ?? 0, + discoveriesGenerated: updateProps.attackDiscoveries.length, durationMs, model: apiConfig.model, provider: apiConfig.provider, @@ -229,6 +350,70 @@ export const updateAttackDiscoveries = async ({ } }; +export const handleToolError = async ({ + apiConfig, + attackDiscoveryId, + authenticatedUser, + dataClient, + err, + latestReplacements, + logger, + telemetry, +}: { + apiConfig: ApiConfig; + attackDiscoveryId: string; + authenticatedUser: AuthenticatedUser; + dataClient: AttackDiscoveryDataClient; + err: Error; + latestReplacements: Replacements; + logger: Logger; + telemetry: AnalyticsServiceSetup; +}) => { + try { + logger.error(err); + const error = transformError(err); + const currentAd = await dataClient.getAttackDiscovery({ + id: attackDiscoveryId, + authenticatedUser, + }); + + if (currentAd === null || currentAd?.status === 'canceled') { + return; + } + await dataClient.updateAttackDiscovery({ + attackDiscoveryUpdateProps: { + attackDiscoveries: [], + status: attackDiscoveryStatus.failed, + id: attackDiscoveryId, + replacements: latestReplacements, + backingIndex: currentAd.backingIndex, + failureReason: error.message, + }, + authenticatedUser, + }); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: error.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } catch (updateErr) { + const updateError = transformError(updateErr); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: updateError.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } +}; + +export const getAssistantTool = (getRegisteredTools: GetRegisteredTools, pluginName: string) => { + // get the attack discovery tool: + const assistantTools = getRegisteredTools(pluginName); + return assistantTools.find((tool) => tool.id === 'attack-discovery'); +}; + export const updateAttackDiscoveryLastViewedAt = async ({ connectorId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts deleted file mode 100644 index 2e0a545eb083a..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AuthenticatedUser } from '@kbn/core-security-common'; - -import { getAttackDiscoveryStats } from './helpers'; -import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; -import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; - -jest.mock('lodash/fp', () => ({ - uniq: jest.fn((arr) => Array.from(new Set(arr))), -})); - -jest.mock('@kbn/securitysolution-es-utils', () => ({ - transformError: jest.fn((err) => err), -})); -jest.mock('@kbn/langchain/server', () => ({ - ActionsClientLlm: jest.fn(), -})); -jest.mock('../../evaluate/utils', () => ({ - getLangSmithTracer: jest.fn().mockReturnValue([]), -})); -jest.mock('../../utils', () => ({ - getLlmType: jest.fn().mockReturnValue('llm-type'), -})); -const findAttackDiscoveryByConnectorId = jest.fn(); -const updateAttackDiscovery = jest.fn(); -const createAttackDiscovery = jest.fn(); -const getAttackDiscovery = jest.fn(); -const findAllAttackDiscoveries = jest.fn(); -const mockDataClient = { - findAttackDiscoveryByConnectorId, - updateAttackDiscovery, - createAttackDiscovery, - getAttackDiscovery, - findAllAttackDiscoveries, -} as unknown as AttackDiscoveryDataClient; - -const mockAuthenticatedUser = { - username: 'user', - profile_uid: '1234', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; - -const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; - -describe('helpers', () => { - const date = '2024-03-28T22:27:28.000Z'; - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - beforeEach(() => { - jest.clearAllMocks(); - jest.setSystemTime(new Date(date)); - getAttackDiscovery.mockResolvedValue(mockCurrentAd); - updateAttackDiscovery.mockResolvedValue({}); - }); - - describe('getAttackDiscoveryStats', () => { - const mockDiscoveries = [ - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:47:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:46:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-12T19:54:50.428Z', - id: '745e005b-7248-4c08-b8b6-4cad263b4be0', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T19:54:50.428Z', - updatedAt: '2024-06-17T20:47:27.182Z', - lastViewedAt: '2024-06-17T20:27:27.182Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'running', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gen-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-13T17:50:59.409Z', - id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:50:59.409Z', - updatedAt: '2024-06-17T20:47:59.969Z', - lastViewedAt: '2024-06-17T20:47:35.227Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gpt4o-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T21:18:56.377Z', - id: '82fced1d-de48-42db-9f56-e45122dee017', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T21:18:56.377Z', - updatedAt: '2024-06-17T20:47:39.372Z', - lastViewedAt: '2024-06-17T20:47:39.372Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'canceled', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-bedrock', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T16:44:23.107Z', - id: 'a4709094-6116-484b-b096-1e8d151cb4b7', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T16:44:23.107Z', - updatedAt: '2024-06-17T20:48:16.961Z', - lastViewedAt: '2024-06-17T20:47:16.961Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 0, - apiConfig: { - connectorId: 'my-gen-a2i', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [ - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: 'steph threw an error', - }, - ]; - beforeEach(() => { - findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); - }); - it('returns the formatted stats object', async () => { - const stats = await getAttackDiscoveryStats({ - authenticatedUser: mockAuthenticatedUser, - dataClient: mockDataClient, - }); - expect(stats).toEqual([ - { - hasViewed: true, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'running', - count: 1, - connectorId: 'my-gen-ai', - }, - { - hasViewed: false, - status: 'succeeded', - count: 1, - connectorId: 'my-gpt4o-ai', - }, - { - hasViewed: true, - status: 'canceled', - count: 1, - connectorId: 'my-bedrock', - }, - { - hasViewed: false, - status: 'succeeded', - count: 4, - connectorId: 'my-gen-a2i', - }, - ]); - }); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx deleted file mode 100644 index e58b67bdcc1ad..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; -import { ApiConfig, Replacements } from '@kbn/elastic-assistant-common'; -import { transformError } from '@kbn/securitysolution-es-utils'; - -import { AttackDiscoveryDataClient } from '../../../../../lib/attack_discovery/persistence'; -import { attackDiscoveryStatus } from '../../../helpers/helpers'; -import { ATTACK_DISCOVERY_ERROR_EVENT } from '../../../../../lib/telemetry/event_based_telemetry'; - -export const handleGraphError = async ({ - apiConfig, - attackDiscoveryId, - authenticatedUser, - dataClient, - err, - latestReplacements, - logger, - telemetry, -}: { - apiConfig: ApiConfig; - attackDiscoveryId: string; - authenticatedUser: AuthenticatedUser; - dataClient: AttackDiscoveryDataClient; - err: Error; - latestReplacements: Replacements; - logger: Logger; - telemetry: AnalyticsServiceSetup; -}) => { - try { - logger.error(err); - const error = transformError(err); - const currentAd = await dataClient.getAttackDiscovery({ - id: attackDiscoveryId, - authenticatedUser, - }); - - if (currentAd === null || currentAd?.status === 'canceled') { - return; - } - - await dataClient.updateAttackDiscovery({ - attackDiscoveryUpdateProps: { - attackDiscoveries: [], - status: attackDiscoveryStatus.failed, - id: attackDiscoveryId, - replacements: latestReplacements, - backingIndex: currentAd.backingIndex, - failureReason: error.message, - }, - authenticatedUser, - }); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: error.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } catch (updateErr) { - const updateError = transformError(updateErr); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: updateError.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } -}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx deleted file mode 100644 index 8a8c49f796500..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import { Logger } from '@kbn/core/server'; -import { ApiConfig, AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import type { Document } from '@langchain/core/documents'; - -import { getDefaultAttackDiscoveryGraph } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph'; -import { - ATTACK_DISCOVERY_GRAPH_RUN_NAME, - ATTACK_DISCOVERY_TAG, -} from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/constants'; -import { GraphState } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/types'; -import { throwIfErrorCountsExceeded } from '../throw_if_error_counts_exceeded'; -import { getLlmType } from '../../../../utils'; - -export const invokeAttackDiscoveryGraph = async ({ - actionsClient, - alertsIndexPattern, - anonymizationFields, - apiConfig, - connectorTimeout, - esClient, - langSmithProject, - langSmithApiKey, - latestReplacements, - logger, - onNewReplacements, - size, -}: { - actionsClient: PublicMethodsOf; - alertsIndexPattern: string; - anonymizationFields: AnonymizationFieldResponse[]; - apiConfig: ApiConfig; - connectorTimeout: number; - esClient: ElasticsearchClient; - langSmithProject?: string; - langSmithApiKey?: string; - latestReplacements: Replacements; - logger: Logger; - onNewReplacements: (newReplacements: Replacements) => void; - size: number; -}): Promise<{ - anonymizedAlerts: Document[]; - attackDiscoveries: AttackDiscovery[] | null; -}> => { - const llmType = getLlmType(apiConfig.actionTypeId); - const model = apiConfig.model; - const tags = [ATTACK_DISCOVERY_TAG, llmType, model].flatMap((tag) => tag ?? []); - - const traceOptions = { - projectName: langSmithProject, - tracers: [ - ...getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: langSmithProject, - logger, - }), - ], - }; - - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: apiConfig.connectorId, - llmType, - logger, - temperature: 0, // zero temperature for attack discovery, because we want structured JSON output - timeout: connectorTimeout, - traceOptions, - }); - - if (llm == null) { - throw new Error('LLM is required for attack discoveries'); - } - - const graph = getDefaultAttackDiscoveryGraph({ - alertsIndexPattern, - anonymizationFields, - esClient, - llm, - logger, - onNewReplacements, - replacements: latestReplacements, - size, - }); - - logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph'); - - const result: GraphState = await graph.invoke( - {}, - { - callbacks: [...(traceOptions?.tracers ?? [])], - runName: ATTACK_DISCOVERY_GRAPH_RUN_NAME, - tags, - } - ); - const { - attackDiscoveries, - anonymizedAlerts, - errors, - generationAttempts, - hallucinationFailures, - maxGenerationAttempts, - maxHallucinationFailures, - } = result; - - throwIfErrorCountsExceeded({ - errors, - generationAttempts, - hallucinationFailures, - logger, - maxGenerationAttempts, - maxHallucinationFailures, - }); - - return { anonymizedAlerts, attackDiscoveries }; -}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx deleted file mode 100644 index 9cbf3fa06510d..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaRequest } from '@kbn/core-http-server'; -import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; - -import { mockAnonymizationFields } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields'; -import { requestIsValid } from '.'; - -describe('requestIsValid', () => { - const alertsIndexPattern = '.alerts-security.alerts-default'; - const replacements = { uuid: 'original_value' }; - const size = 20; - const request = { - body: { - actionTypeId: '.bedrock', - alertsIndexPattern, - anonymizationFields: mockAnonymizationFields, - connectorId: 'test-connector-id', - replacements, - size, - subAction: 'invokeAI', - }, - } as unknown as KibanaRequest; - - it('returns false when the request is missing required anonymization parameters', () => { - const requestMissingAnonymizationParams = { - body: { - alertsIndexPattern: '.alerts-security.alerts-default', - isEnabledKnowledgeBase: false, - size: 20, - }, - } as unknown as KibanaRequest; - - const params = { - alertsIndexPattern, - request: requestMissingAnonymizationParams, // <-- missing required anonymization parameters - size, - }; - - expect(requestIsValid(params)).toBe(false); - }); - - it('returns false when the alertsIndexPattern is undefined', () => { - const params = { - alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined - request, - size, - }; - - expect(requestIsValid(params)).toBe(false); - }); - - it('returns false when size is undefined', () => { - const params = { - alertsIndexPattern, - request, - size: undefined, // <-- size is undefined - }; - - expect(requestIsValid(params)).toBe(false); - }); - - it('returns false when size is out of range', () => { - const params = { - alertsIndexPattern, - request, - size: 0, // <-- size is out of range - }; - - expect(requestIsValid(params)).toBe(false); - }); - - it('returns true if all required params are provided', () => { - const params = { - alertsIndexPattern, - request, - size, - }; - - expect(requestIsValid(params)).toBe(true); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx deleted file mode 100644 index 36487d8f6b3e2..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { KibanaRequest } from '@kbn/core/server'; -import { - AttackDiscoveryPostRequestBody, - ExecuteConnectorRequestBody, - sizeIsOutOfRange, -} from '@kbn/elastic-assistant-common'; - -import { requestHasRequiredAnonymizationParams } from '../../../../../lib/langchain/helpers'; - -export const requestIsValid = ({ - alertsIndexPattern, - request, - size, -}: { - alertsIndexPattern: string | undefined; - request: KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >; - size: number | undefined; -}): boolean => - requestHasRequiredAnonymizationParams(request) && - alertsIndexPattern != null && - size != null && - !sizeIsOutOfRange(size); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts deleted file mode 100644 index 409ee2da74cd2..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger } from '@kbn/core/server'; - -import * as i18n from './translations'; - -export const throwIfErrorCountsExceeded = ({ - errors, - generationAttempts, - hallucinationFailures, - logger, - maxGenerationAttempts, - maxHallucinationFailures, -}: { - errors: string[]; - generationAttempts: number; - hallucinationFailures: number; - logger?: Logger; - maxGenerationAttempts: number; - maxHallucinationFailures: number; -}): void => { - if (hallucinationFailures >= maxHallucinationFailures) { - const hallucinationFailuresError = `${i18n.MAX_HALLUCINATION_FAILURES( - hallucinationFailures - )}\n${errors.join(',\n')}`; - - logger?.error(hallucinationFailuresError); - throw new Error(hallucinationFailuresError); - } - - if (generationAttempts >= maxGenerationAttempts) { - const generationAttemptsError = `${i18n.MAX_GENERATION_ATTEMPTS( - generationAttempts - )}\n${errors.join(',\n')}`; - - logger?.error(generationAttemptsError); - throw new Error(generationAttemptsError); - } -}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts deleted file mode 100644 index fbe06d0e73b2a..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const MAX_HALLUCINATION_FAILURES = (hallucinationFailures: number) => - i18n.translate( - 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage', - { - defaultMessage: - 'Maximum hallucination failures ({hallucinationFailures}) reached. Try sending fewer alerts to this model.', - values: { hallucinationFailures }, - } - ); - -export const MAX_GENERATION_ATTEMPTS = (generationAttempts: number) => - i18n.translate( - 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage', - { - defaultMessage: - 'Maximum generation attempts ({generationAttempts}) reached. Try sending fewer alerts to this model.', - values: { generationAttempts }, - } - ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts index d50987317b0e3..cbd3e6063fbd2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts @@ -7,27 +7,22 @@ import { AuthenticatedUser } from '@kbn/core-security-common'; import { postAttackDiscoveryRoute } from './post_attack_discovery'; -import { serverMock } from '../../../__mocks__/server'; -import { requestContextMock } from '../../../__mocks__/request_context'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; -import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; -import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; -import { postAttackDiscoveryRequest } from '../../../__mocks__/request'; +import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { postAttackDiscoveryRequest } from '../../__mocks__/request'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; import { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; - -import { updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; - -jest.mock('../helpers/helpers', () => { - const original = jest.requireActual('../helpers/helpers'); - - return { - ...original, - updateAttackDiscoveryStatusToRunning: jest.fn(), - }; -}); +import { + getAssistantTool, + getAssistantToolParams, + updateAttackDiscoveryStatusToRunning, +} from './helpers'; +jest.mock('./helpers'); const { clients, context } = requestContextMock.createTools(); const server: ReturnType = serverMock.create(); @@ -77,6 +72,8 @@ describe('postAttackDiscoveryRoute', () => { context.elasticAssistant.actions = actionsMock.createStart(); postAttackDiscoveryRoute(server.router); findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd); + (getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() }); + (getAssistantToolParams as jest.Mock).mockReturnValue({ tool: 'tool' }); (updateAttackDiscoveryStatusToRunning as jest.Mock).mockResolvedValue({ currentAd: runningAd, attackDiscoveryId: mockCurrentAd.id, @@ -120,6 +117,15 @@ describe('postAttackDiscoveryRoute', () => { }); }); + it('should handle assistantTool null response', async () => { + (getAssistantTool as jest.Mock).mockReturnValue(null); + const response = await server.inject( + postAttackDiscoveryRequest(mockRequestBody), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + it('should handle updateAttackDiscoveryStatusToRunning error', async () => { (updateAttackDiscoveryStatusToRunning as jest.Mock).mockRejectedValue(new Error('Oh no!')); const response = await server.inject( diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts index b0273741bdf5e..b9c680dde3d1d 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; import { AttackDiscoveryPostRequestBody, @@ -12,17 +13,20 @@ import { ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, Replacements, } from '@kbn/elastic-assistant-common'; -import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { transformError } from '@kbn/securitysolution-es-utils'; import moment from 'moment/moment'; -import { ATTACK_DISCOVERY } from '../../../../common/constants'; -import { handleGraphError } from './helpers/handle_graph_error'; -import { updateAttackDiscoveries, updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; -import { buildResponse } from '../../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../../types'; -import { invokeAttackDiscoveryGraph } from './helpers/invoke_attack_discovery_graph'; -import { requestIsValid } from './helpers/request_is_valid'; +import { ATTACK_DISCOVERY } from '../../../common/constants'; +import { + getAssistantTool, + getAssistantToolParams, + handleToolError, + updateAttackDiscoveries, + updateAttackDiscoveryStatusToRunning, +} from './helpers'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; +import { buildResponse } from '../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds @@ -81,6 +85,11 @@ export const postAttackDiscoveryRoute = ( statusCode: 500, }); } + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); // get parameters from the request body const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern); @@ -93,19 +102,6 @@ export const postAttackDiscoveryRoute = ( size, } = request.body; - if ( - !requestIsValid({ - alertsIndexPattern, - request, - size, - }) - ) { - return resp.error({ - body: 'Bad Request', - statusCode: 400, - }); - } - // get an Elasticsearch client for the authenticated user: const esClient = (await context.core).elasticsearch.client.asCurrentUser; @@ -115,45 +111,59 @@ export const postAttackDiscoveryRoute = ( latestReplacements = { ...latestReplacements, ...newReplacements }; }; - const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( - dataClient, - authenticatedUser, - apiConfig, - size + const assistantTool = getAssistantTool( + (await context.elasticAssistant).getRegisteredTools, + pluginName ); - // Don't await the results of invoking the graph; (just the metadata will be returned from the route handler): - invokeAttackDiscoveryGraph({ + if (!assistantTool) { + return response.notFound(); // attack discovery tool not found + } + + const assistantToolParams = getAssistantToolParams({ actionsClient, alertsIndexPattern, anonymizationFields, apiConfig, - connectorTimeout: CONNECTOR_TIMEOUT, esClient, + latestReplacements, + connectorTimeout: CONNECTOR_TIMEOUT, + langChainTimeout: LANG_CHAIN_TIMEOUT, langSmithProject, langSmithApiKey, - latestReplacements, logger, onNewReplacements, + request, size, - }) - .then(({ anonymizedAlerts, attackDiscoveries }) => + }); + + // invoke the attack discovery tool: + const toolInstance = assistantTool.getTool(assistantToolParams); + + const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( + dataClient, + authenticatedUser, + apiConfig + ); + + toolInstance + ?.invoke('') + .then((rawAttackDiscoveries: string) => updateAttackDiscoveries({ - anonymizedAlerts, apiConfig, - attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, + rawAttackDiscoveries, size, startTime, telemetry, }) ) .catch((err) => - handleGraphError({ + handleToolError({ apiConfig, attackDiscoveryId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts deleted file mode 100644 index c0320c9ff6adf..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - ASSISTANT_GRAPH_MAP, - AssistantGraphMetadata, - AttackDiscoveryGraphMetadata, -} from '../../../lib/langchain/graphs'; - -export interface GetGraphsFromNamesResults { - attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; - assistantGraphs: AssistantGraphMetadata[]; -} - -export const getGraphsFromNames = (graphNames: string[]): GetGraphsFromNamesResults => - graphNames.reduce( - (acc, graphName) => { - const graph = ASSISTANT_GRAPH_MAP[graphName]; - if (graph != null) { - return graph.graphType === 'assistant' - ? { ...acc, assistantGraphs: [...acc.assistantGraphs, graph] } - : { ...acc, attackDiscoveryGraphs: [...acc.attackDiscoveryGraphs, graph] }; - } - - return acc; - }, - { - attackDiscoveryGraphs: [], - assistantGraphs: [], - } - ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index eb12946a9b61f..29a7527964677 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -29,7 +29,6 @@ import { createStructuredChatAgent, createToolCallingAgent, } from 'langchain/agents'; -import { omit } from 'lodash/fp'; import { buildResponse } from '../../lib/build_response'; import { AssistantDataClients } from '../../lib/langchain/executors/types'; import { AssistantToolParams, ElasticAssistantRequestHandlerContext, GetElser } from '../../types'; @@ -37,7 +36,6 @@ import { DEFAULT_PLUGIN_NAME, isV2KnowledgeBaseEnabled, performChecks } from '.. import { fetchLangSmithDataset } from './utils'; import { transformESSearchToAnonymizationFields } from '../../ai_assistant_data_clients/anonymization_fields/helpers'; import { EsAnonymizationFieldsSchema } from '../../ai_assistant_data_clients/anonymization_fields/types'; -import { evaluateAttackDiscovery } from '../../lib/attack_discovery/evaluation'; import { DefaultAssistantGraph, getDefaultAssistantGraph, @@ -49,12 +47,9 @@ import { structuredChatAgentPrompt, } from '../../lib/langchain/graphs/default_assistant_graph/prompts'; import { getLlmClass, getLlmType, isOpenSourceModel } from '../utils'; -import { getGraphsFromNames } from './get_graphs_from_names'; const DEFAULT_SIZE = 20; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes -const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds -const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds export const postEvaluateRoute = ( router: IRouter, @@ -111,10 +106,8 @@ export const postEvaluateRoute = ( const { alertsIndexPattern, datasetName, - evaluatorConnectorId, graphs: graphNames, langSmithApiKey, - langSmithProject, connectorIds, size, replacements, @@ -131,9 +124,7 @@ export const postEvaluateRoute = ( logger.info('postEvaluateRoute:'); logger.info(`request.query:\n${JSON.stringify(request.query, null, 2)}`); - logger.info( - `request.body:\n${JSON.stringify(omit(['langSmithApiKey'], request.body), null, 2)}` - ); + logger.info(`request.body:\n${JSON.stringify(request.body, null, 2)}`); logger.info(`Evaluation ID: ${evaluationId}`); const totalExecutions = connectorIds.length * graphNames.length * dataset.length; @@ -179,38 +170,6 @@ export const postEvaluateRoute = ( // Fetch any tools registered to the security assistant const assistantTools = assistantContext.getRegisteredTools(DEFAULT_PLUGIN_NAME); - const { attackDiscoveryGraphs } = getGraphsFromNames(graphNames); - - if (attackDiscoveryGraphs.length > 0) { - try { - // NOTE: we don't wait for the evaluation to finish here, because - // the client will retry / timeout when evaluations take too long - void evaluateAttackDiscovery({ - actionsClient, - alertsIndexPattern, - attackDiscoveryGraphs, - connectors, - connectorTimeout: CONNECTOR_TIMEOUT, - datasetName, - esClient, - evaluationId, - evaluatorConnectorId, - langSmithApiKey, - langSmithProject, - logger, - runName, - size, - }); - } catch (err) { - logger.error(() => `Error evaluating attack discovery: ${err}`); - } - - // Return early if we're only running attack discovery graphs - return response.ok({ - body: { evaluationId, success: true }, - }); - } - const graphs: Array<{ name: string; graph: DefaultAssistantGraph; diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts index 0260c47b4bd29..34f009e266515 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts @@ -21,7 +21,7 @@ export const fetchLangSmithDataset = async ( logger: Logger, langSmithApiKey?: string ): Promise => { - if (datasetName === undefined || (langSmithApiKey == null && !isLangSmithEnabled())) { + if (datasetName === undefined || !isLangSmithEnabled()) { throw new Error('LangSmith dataset name not provided or LangSmith not enabled'); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index a6d7a4298c2b7..43e1229250f46 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -9,8 +9,8 @@ export { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; // Attack Discovery -export { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; -export { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; +export { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; +export { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; // Knowledge Base export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index 7898629e15b5c..56eb9760e442a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -7,9 +7,9 @@ import type { Logger } from '@kbn/core/server'; -import { cancelAttackDiscoveryRoute } from './attack_discovery/post/cancel/cancel_attack_discovery'; -import { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; -import { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; +import { cancelAttackDiscoveryRoute } from './attack_discovery/cancel_attack_discovery'; +import { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; +import { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; import { ElasticAssistantPluginRouter, GetElser } from '../types'; import { createConversationRoute } from './user_conversations/create_route'; import { deleteConversationRoute } from './user_conversations/delete_route'; diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index e84b97ab43d7a..45bd5a4149b58 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -43,10 +43,10 @@ import { ActionsClientGeminiChatModel, ActionsClientLlm, } from '@kbn/langchain/server'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; import type { GetAIAssistantKnowledgeBaseDataClientParams } from './ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from './lib/attack_discovery/persistence'; +import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery'; import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations'; import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; import { AIAssistantDataClient } from './ai_assistant_data_clients'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx index dd995d115b6c3..885ab18c879a7 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx @@ -6,11 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import { - replaceAnonymizedValuesWithOriginalValues, - type AttackDiscovery, - type Replacements, -} from '@kbn/elastic-assistant-common'; +import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; import { AttackDiscoveryMarkdownFormatter } from '../../attack_discovery_markdown_formatter'; @@ -27,41 +23,26 @@ const ActionableSummaryComponent: React.FC = ({ replacements, showAnonymized = false, }) => { - const entitySummary = useMemo( + const entitySummaryMarkdownWithReplacements = useMemo( () => - showAnonymized - ? attackDiscovery.entitySummaryMarkdown - : replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.entitySummaryMarkdown ?? '', - replacements: { ...replacements }, - }), - - [attackDiscovery.entitySummaryMarkdown, replacements, showAnonymized] - ); - - // title will be used as a fallback if entitySummaryMarkdown is empty - const title = useMemo( - () => - showAnonymized - ? attackDiscovery.title - : replaceAnonymizedValuesWithOriginalValues({ - messageContent: attackDiscovery.title, - replacements: { ...replacements }, - }), - - [attackDiscovery.title, replacements, showAnonymized] + Object.entries(replacements ?? {}).reduce( + (acc, [key, value]) => acc.replace(key, value), + attackDiscovery.entitySummaryMarkdown + ), + [attackDiscovery.entitySummaryMarkdown, replacements] ); - const entitySummaryOrTitle = - entitySummary != null && entitySummary.length > 0 ? entitySummary : title; - return ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx index c6ac9c70e8413..2aaac0449886a 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx @@ -49,15 +49,8 @@ const AttackDiscoveryPanelComponent: React.FC = ({ ); const buttonContent = useMemo( - () => ( - - ), - [attackDiscovery.title, replacements, showAnonymized] + () => <Title isLoading={false} title={attackDiscovery.title} />, + [attackDiscovery.title] ); return ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx index 13326a07adc70..4b0375e4fe503 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx @@ -7,41 +7,20 @@ import { EuiFlexGroup, EuiFlexItem, EuiSkeletonTitle, EuiTitle, useEuiTheme } from '@elastic/eui'; import { AssistantAvatar } from '@kbn/elastic-assistant'; -import { - replaceAnonymizedValuesWithOriginalValues, - type Replacements, -} from '@kbn/elastic-assistant-common'; import { css } from '@emotion/react'; -import React, { useMemo } from 'react'; +import React from 'react'; const AVATAR_SIZE = 24; // px interface Props { isLoading: boolean; - replacements?: Replacements; - showAnonymized?: boolean; title: string; } -const TitleComponent: React.FC<Props> = ({ - isLoading, - replacements, - showAnonymized = false, - title, -}) => { +const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { const { euiTheme } = useEuiTheme(); - const titleWithReplacements = useMemo( - () => - replaceAnonymizedValuesWithOriginalValues({ - messageContent: title, - replacements: { ...replacements }, - }), - - [replacements, title] - ); - return ( <EuiFlexGroup alignItems="center" data-test-subj="title" gutterSize="s"> <EuiFlexItem @@ -74,7 +53,7 @@ const TitleComponent: React.FC<Props> = ({ /> ) : ( <EuiTitle data-test-subj="titleText" size="xs"> - <h2>{showAnonymized ? title : titleWithReplacements}</h2> + <h2>{title}</h2> </EuiTitle> )} </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts index 0ae524c25ee95..5309ef1de6bb2 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts @@ -56,7 +56,7 @@ export const getAttackDiscoveryMarkdown = ({ replacements?: Replacements; }): string => { const title = getMarkdownFields(attackDiscovery.title); - const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown ?? ''); + const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown); const summaryMarkdown = getMarkdownFields(attackDiscovery.summaryMarkdown); const detailsMarkdown = getMarkdownFields(attackDiscovery.detailsMarkdown); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx index ab0a5ac4ede96..874a4d1c99ded 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx @@ -106,9 +106,7 @@ export const usePollApi = ({ ...attackDiscovery, id: attackDiscovery.id ?? uuid.v4(), detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown), - entitySummaryMarkdown: replaceNewlineLiterals( - attackDiscovery.entitySummaryMarkdown ?? '' - ), + entitySummaryMarkdown: replaceNewlineLiterals(attackDiscovery.entitySummaryMarkdown), summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown), })), }; @@ -125,7 +123,7 @@ export const usePollApi = ({ const rawResponse = await http.fetch( `/internal/elastic_assistant/attack_discovery/cancel/${connectorId}`, { - method: 'POST', + method: 'PUT', version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, } ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx index 533b95bf7087f..5dd4cb8fc4267 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx @@ -52,7 +52,7 @@ const AnimatedCounterComponent: React.FC<Props> = ({ animationDurationMs = 1000 css={css` height: 32px; margin-right: ${euiTheme.size.xs}; - width: ${count < 100 ? 40 : 60}px; + width: ${count < 100 ? 40 : 53}px; `} data-test-subj="animatedCounter" ref={d3Ref} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx index 0707950383046..56b2205b28726 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx @@ -16,8 +16,6 @@ jest.mock('../../../assistant/use_assistant_availability'); describe('EmptyPrompt', () => { const alertsCount = 20; - const aiConnectorsCount = 2; - const attackDiscoveriesCount = 0; const onGenerate = jest.fn(); beforeEach(() => { @@ -35,8 +33,6 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} - aiConnectorsCount={aiConnectorsCount} - attackDiscoveriesCount={attackDiscoveriesCount} isLoading={false} isDisabled={false} onGenerate={onGenerate} @@ -73,34 +69,8 @@ describe('EmptyPrompt', () => { }); describe('when loading is true', () => { - beforeEach(() => { - (useAssistantAvailability as jest.Mock).mockReturnValue({ - hasAssistantPrivilege: true, - isAssistantEnabled: true, - }); - - render( - <TestProviders> - <EmptyPrompt - aiConnectorsCount={2} // <-- non-null - attackDiscoveriesCount={0} // <-- no discoveries - alertsCount={alertsCount} - isLoading={true} // <-- loading - isDisabled={false} - onGenerate={onGenerate} - /> - </TestProviders> - ); - }); - - it('returns null', () => { - const emptyPrompt = screen.queryByTestId('emptyPrompt'); + const isLoading = true; - expect(emptyPrompt).not.toBeInTheDocument(); - }); - }); - - describe('when aiConnectorsCount is null', () => { beforeEach(() => { (useAssistantAvailability as jest.Mock).mockReturnValue({ hasAssistantPrivilege: true, @@ -110,10 +80,8 @@ describe('EmptyPrompt', () => { render( <TestProviders> <EmptyPrompt - aiConnectorsCount={null} // <-- null - attackDiscoveriesCount={0} // <-- no discoveries alertsCount={alertsCount} - isLoading={false} // <-- not loading + isLoading={isLoading} isDisabled={false} onGenerate={onGenerate} /> @@ -121,38 +89,10 @@ describe('EmptyPrompt', () => { ); }); - it('returns null', () => { - const emptyPrompt = screen.queryByTestId('emptyPrompt'); - - expect(emptyPrompt).not.toBeInTheDocument(); - }); - }); - - describe('when there are attack discoveries', () => { - beforeEach(() => { - (useAssistantAvailability as jest.Mock).mockReturnValue({ - hasAssistantPrivilege: true, - isAssistantEnabled: true, - }); - - render( - <TestProviders> - <EmptyPrompt - aiConnectorsCount={2} // <-- non-null - attackDiscoveriesCount={7} // there are discoveries - alertsCount={alertsCount} - isLoading={false} // <-- not loading - isDisabled={false} - onGenerate={onGenerate} - /> - </TestProviders> - ); - }); - - it('returns null', () => { - const emptyPrompt = screen.queryByTestId('emptyPrompt'); + it('disables the generate button while loading', () => { + const generateButton = screen.getByTestId('generate'); - expect(emptyPrompt).not.toBeInTheDocument(); + expect(generateButton).toBeDisabled(); }); }); @@ -169,8 +109,6 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} - aiConnectorsCount={2} // <-- non-null - attackDiscoveriesCount={0} // <-- no discoveries isLoading={false} isDisabled={isDisabled} onGenerate={onGenerate} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx index 3d89f5be87030..75c8533efcc92 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx @@ -7,6 +7,7 @@ import { AssistantAvatar } from '@kbn/elastic-assistant'; import { + EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, @@ -14,28 +15,24 @@ import { EuiLink, EuiSpacer, EuiText, + EuiToolTip, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { AnimatedCounter } from './animated_counter'; -import { Generate } from '../generate'; import * as i18n from './translations'; interface Props { - aiConnectorsCount: number | null; // null when connectors are not configured alertsCount: number; - attackDiscoveriesCount: number; isDisabled?: boolean; isLoading: boolean; onGenerate: () => void; } const EmptyPromptComponent: React.FC<Props> = ({ - aiConnectorsCount, alertsCount, - attackDiscoveriesCount, isLoading, isDisabled = false, onGenerate, @@ -113,12 +110,24 @@ const EmptyPromptComponent: React.FC<Props> = ({ ); const actions = useMemo(() => { - return <Generate isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; - }, [isDisabled, isLoading, onGenerate]); + const disabled = isLoading || isDisabled; - if (isLoading || aiConnectorsCount == null || attackDiscoveriesCount > 0) { - return null; - } + return ( + <EuiToolTip + content={disabled ? i18n.SELECT_A_CONNECTOR : null} + data-test-subj="generateTooltip" + > + <EuiButton + color="primary" + data-test-subj="generate" + disabled={disabled} + onClick={onGenerate} + > + {i18n.GENERATE} + </EuiButton> + </EuiToolTip> + ); + }, [isDisabled, isLoading, onGenerate]); return ( <EuiFlexGroup diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts deleted file mode 100644 index e2c7018ef5826..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - showEmptyPrompt, - showFailurePrompt, - showNoAlertsPrompt, - showWelcomePrompt, -} from '../../../helpers'; - -export const showEmptyStates = ({ - aiConnectorsCount, - alertsContextCount, - attackDiscoveriesCount, - connectorId, - failureReason, - isLoading, -}: { - aiConnectorsCount: number | null; - alertsContextCount: number | null; - attackDiscoveriesCount: number; - connectorId: string | undefined; - failureReason: string | null; - isLoading: boolean; -}): boolean => { - const showWelcome = showWelcomePrompt({ aiConnectorsCount, isLoading }); - const showFailure = showFailurePrompt({ connectorId, failureReason, isLoading }); - const showNoAlerts = showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading }); - const showEmpty = showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading }); - - return showWelcome || showFailure || showNoAlerts || showEmpty; -}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx index 9eacd696a2ff1..3b5b87ada83ec 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { render, screen } from '@testing-library/react'; import React from 'react'; @@ -19,6 +18,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured const alertsContextCount = null; + const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -29,12 +29,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -59,6 +59,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 0; // <-- no alerts to analyze + const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; @@ -69,12 +70,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -103,7 +104,8 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const attackDiscoveriesCount = 0; + const alertsCount = 10; + const attackDiscoveriesCount = 10; const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -113,12 +115,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={"you're a failure"} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -147,7 +149,8 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const attackDiscoveriesCount = 0; + const alertsCount = 10; + const attackDiscoveriesCount = 10; const connectorId = 'test-connector-id'; const failureReason = 'this failure should NOT be displayed, because we are loading'; // <-- failureReason is provided const isLoading = true; // <-- loading data @@ -158,12 +161,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={failureReason} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -192,7 +195,8 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const attackDiscoveriesCount = 0; + const alertsCount = 0; // <-- no alerts contributed to attack discoveries + const attackDiscoveriesCount = 0; // <-- no attack discoveries were generated from the alerts const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -202,12 +206,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -236,6 +240,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = null; // <-- no connectors configured const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed + const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -246,12 +251,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -282,6 +287,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured (welcome prompt should be shown if not loading) const alertsContextCount = null; + const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = true; // <-- loading data @@ -292,12 +298,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -332,7 +338,8 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const attackDiscoveriesCount = 7; // <-- attack discoveries are present + const alertsCount = 10; // <-- alerts contributed to attack discoveries + const attackDiscoveriesCount = 3; // <-- attack discoveries were generated from the alerts const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -342,12 +349,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} + alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} - upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx index a083ec7b77fdd..49b4557c72192 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx @@ -9,55 +9,51 @@ import React from 'react'; import { Failure } from '../failure'; import { EmptyPrompt } from '../empty_prompt'; -import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; +import { showEmptyPrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; import { NoAlerts } from '../no_alerts'; import { Welcome } from '../welcome'; interface Props { - aiConnectorsCount: number | null; // null when connectors are not configured - alertsContextCount: number | null; // null when unavailable for the current connector + aiConnectorsCount: number | null; + alertsContextCount: number | null; + alertsCount: number; attackDiscoveriesCount: number; connectorId: string | undefined; failureReason: string | null; isLoading: boolean; onGenerate: () => Promise<void>; - upToAlertsCount: number; } const EmptyStatesComponent: React.FC<Props> = ({ aiConnectorsCount, alertsContextCount, + alertsCount, attackDiscoveriesCount, connectorId, failureReason, isLoading, onGenerate, - upToAlertsCount, }) => { - const isDisabled = connectorId == null; - if (showWelcomePrompt({ aiConnectorsCount, isLoading })) { return <Welcome />; - } - - if (showFailurePrompt({ connectorId, failureReason, isLoading })) { + } else if (!isLoading && failureReason != null) { return <Failure failureReason={failureReason} />; + } else if (showNoAlertsPrompt({ alertsContextCount, isLoading })) { + return <NoAlerts />; + } else if (showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading })) { + return ( + <EmptyPrompt + alertsCount={alertsCount} + isDisabled={connectorId == null} + isLoading={isLoading} + onGenerate={onGenerate} + /> + ); } - if (showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading })) { - return <NoAlerts isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; - } - - return ( - <EmptyPrompt - aiConnectorsCount={aiConnectorsCount} - alertsCount={upToAlertsCount} - attackDiscoveriesCount={attackDiscoveriesCount} - isDisabled={isDisabled} - isLoading={isLoading} - onGenerate={onGenerate} - /> - ); + return null; }; +EmptyStatesComponent.displayName = 'EmptyStates'; + export const EmptyStates = React.memo(EmptyStatesComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx index c9c27446fe51c..4318f3f78536a 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx @@ -5,53 +5,13 @@ * 2.0. */ -import { - EuiAccordion, - EuiCodeBlock, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiText, -} from '@elastic/eui'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; -import React, { useMemo } from 'react'; +import React from 'react'; import * as i18n from './translations'; -interface Props { - failureReason: string | null | undefined; -} - -const FailureComponent: React.FC<Props> = ({ failureReason }) => { - const Failures = useMemo(() => { - const failures = failureReason != null ? failureReason.split('\n') : ''; - const [firstFailure, ...restFailures] = failures; - - return ( - <> - <p>{firstFailure}</p> - - {restFailures.length > 0 && ( - <EuiAccordion - id="failuresFccordion" - buttonContent={i18n.DETAILS} - data-test-subj="failuresAccordion" - paddingSize="s" - > - <> - {restFailures.map((failure, i) => ( - <EuiCodeBlock fontSize="m" key={i} paddingSize="m"> - {failure} - </EuiCodeBlock> - ))} - </> - </EuiAccordion> - )} - </> - ); - }, [failureReason]); - +const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason }) => { return ( <EuiFlexGroup alignItems="center" data-test-subj="failure" direction="column"> <EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}> @@ -66,7 +26,7 @@ const FailureComponent: React.FC<Props> = ({ failureReason }) => { `} data-test-subj="bodyText" > - {Failures} + {failureReason} </EuiText> } title={<h2 data-test-subj="failureTitle">{i18n.FAILURE_TITLE}</h2>} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts index ecaa7fad240e1..b36104d202ba8 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const DETAILS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.pages.failure.detailsAccordionButton', +export const LEARN_MORE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', { - defaultMessage: 'Details', + defaultMessage: 'Learn more about Attack discovery', } ); @@ -20,10 +20,3 @@ export const FAILURE_TITLE = i18n.translate( defaultMessage: 'Attack discovery generation failed', } ); - -export const LEARN_MORE = i18n.translate( - 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', - { - defaultMessage: 'Learn more about Attack discovery', - } -); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx deleted file mode 100644 index 16ed376dd3af4..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiButton, EuiToolTip } from '@elastic/eui'; -import React from 'react'; - -import * as i18n from '../empty_prompt/translations'; - -interface Props { - isDisabled?: boolean; - isLoading: boolean; - onGenerate: () => void; -} - -const GenerateComponent: React.FC<Props> = ({ isLoading, isDisabled = false, onGenerate }) => { - const disabled = isLoading || isDisabled; - - return ( - <EuiToolTip - content={disabled ? i18n.SELECT_A_CONNECTOR : null} - data-test-subj="generateTooltip" - > - <EuiButton color="primary" data-test-subj="generate" disabled={disabled} onClick={onGenerate}> - {i18n.GENERATE} - </EuiButton> - </EuiToolTip> - ); -}; - -GenerateComponent.displayName = 'Generate'; - -export const Generate = React.memo(GenerateComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index 7b0688eadafef..aee53d889c7ac 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; @@ -32,11 +31,9 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -57,11 +54,9 @@ describe('Header', () => { connectorsAreConfigured={connectorsAreConfigured} isDisabledActions={false} isLoading={false} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -82,11 +77,9 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={onGenerate} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -109,11 +102,9 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -135,11 +126,9 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={onCancel} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -161,11 +150,9 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} - localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} - setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx index ff170805670a6..583bcc25d0eb6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx @@ -9,11 +9,10 @@ import type { EuiButtonProps } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; -import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { SettingsModal } from './settings_modal'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { StatusBell } from './status_bell'; import * as i18n from './translations'; @@ -22,11 +21,9 @@ interface Props { connectorsAreConfigured: boolean; isLoading: boolean; isDisabledActions: boolean; - localStorageAttackDiscoveryMaxAlerts: string | undefined; onGenerate: () => void; onCancel: () => void; onConnectorIdSelected: (connectorId: string) => void; - setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; stats: AttackDiscoveryStats | null; } @@ -35,11 +32,9 @@ const HeaderComponent: React.FC<Props> = ({ connectorsAreConfigured, isLoading, isDisabledActions, - localStorageAttackDiscoveryMaxAlerts, onGenerate, onConnectorIdSelected, onCancel, - setLocalStorageAttackDiscoveryMaxAlerts, stats, }) => { const { euiTheme } = useEuiTheme(); @@ -73,7 +68,6 @@ const HeaderComponent: React.FC<Props> = ({ }, [isLoading, handleCancel, onGenerate] ); - return ( <EuiFlexGroup alignItems="center" @@ -84,14 +78,6 @@ const HeaderComponent: React.FC<Props> = ({ data-test-subj="header" gutterSize="none" > - <EuiFlexItem grow={false}> - <SettingsModal - connectorId={connectorId} - isLoading={isLoading} - localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} - setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} - /> - </EuiFlexItem> <StatusBell stats={stats} /> {connectorsAreConfigured && ( <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx deleted file mode 100644 index b51a1fc3f85c8..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SingleRangeChangeEvent } from '@kbn/elastic-assistant'; -import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; -import { - AlertsRange, - SELECT_FEWER_ALERTS, - YOUR_ANONYMIZATION_SETTINGS, -} from '@kbn/elastic-assistant'; -import React, { useCallback } from 'react'; - -import * as i18n from '../translations'; - -export const MAX_ALERTS = 500; -export const MIN_ALERTS = 50; -export const ROW_MIN_WITH = 550; // px -export const STEP = 50; - -interface Props { - maxAlerts: string; - setMaxAlerts: React.Dispatch<React.SetStateAction<string>>; -} - -const AlertsSettingsComponent: React.FC<Props> = ({ maxAlerts, setMaxAlerts }) => { - const onChangeAlertsRange = useCallback( - (e: SingleRangeChangeEvent) => { - setMaxAlerts(e.currentTarget.value); - }, - [setMaxAlerts] - ); - - return ( - <EuiForm component="form"> - <EuiFormRow hasChildLabel={false} label={i18n.ALERTS}> - <EuiFlexGroup direction="column" gutterSize="none"> - <EuiFlexItem grow={false}> - <AlertsRange - maxAlerts={MAX_ALERTS} - minAlerts={MIN_ALERTS} - onChange={onChangeAlertsRange} - step={STEP} - value={maxAlerts} - /> - <EuiSpacer size="m" /> - </EuiFlexItem> - - <EuiFlexItem grow={true}> - <EuiText color="subdued" size="xs"> - <span>{i18n.LATEST_AND_RISKIEST_OPEN_ALERTS(Number(maxAlerts))}</span> - </EuiText> - </EuiFlexItem> - - <EuiFlexItem grow={true}> - <EuiText color="subdued" size="xs"> - <span>{YOUR_ANONYMIZATION_SETTINGS}</span> - </EuiText> - </EuiFlexItem> - - <EuiFlexItem grow={true}> - <EuiText color="subdued" size="xs"> - <span>{SELECT_FEWER_ALERTS}</span> - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFormRow> - </EuiForm> - ); -}; - -AlertsSettingsComponent.displayName = 'AlertsSettings'; - -export const AlertsSettings = React.memo(AlertsSettingsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx deleted file mode 100644 index 0066376a0e198..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import React from 'react'; - -import * as i18n from '../translations'; - -interface Props { - closeModal: () => void; - onReset: () => void; - onSave: () => void; -} - -const FooterComponent: React.FC<Props> = ({ closeModal, onReset, onSave }) => { - const { euiTheme } = useEuiTheme(); - - return ( - <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty data-test-sub="reset" flush="both" onClick={onReset} size="s"> - {i18n.RESET} - </EuiButtonEmpty> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <EuiFlexGroup alignItems="center" gutterSize="none"> - <EuiFlexItem - css={css` - margin-right: ${euiTheme.size.s}; - `} - grow={false} - > - <EuiButtonEmpty data-test-sub="cancel" onClick={closeModal} size="s"> - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <EuiButton data-test-sub="save" fill onClick={onSave} size="s"> - {i18n.SAVE} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ); -}; - -FooterComponent.displayName = 'Footer'; - -export const Footer = React.memo(FooterComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx deleted file mode 100644 index 0d342c591f32b..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiButtonIcon, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiText, - EuiToolTip, - EuiTourStep, - useGeneratedHtmlId, -} from '@elastic/eui'; -import { - ATTACK_DISCOVERY_STORAGE_KEY, - DEFAULT_ASSISTANT_NAMESPACE, - DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, - SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, -} from '@kbn/elastic-assistant'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocalStorage } from 'react-use'; - -import { AlertsSettings } from './alerts_settings'; -import { useSpaceId } from '../../../../common/hooks/use_space_id'; -import { Footer } from './footer'; -import { getIsTourEnabled } from './is_tour_enabled'; -import * as i18n from './translations'; - -interface Props { - connectorId: string | undefined; - isLoading: boolean; - localStorageAttackDiscoveryMaxAlerts: string | undefined; - setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; -} - -const SettingsModalComponent: React.FC<Props> = ({ - connectorId, - isLoading, - localStorageAttackDiscoveryMaxAlerts, - setLocalStorageAttackDiscoveryMaxAlerts, -}) => { - const spaceId = useSpaceId() ?? 'default'; - const modalTitleId = useGeneratedHtmlId(); - - const [maxAlerts, setMaxAlerts] = useState( - localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` - ); - - const [isModalVisible, setIsModalVisible] = useState(false); - const showModal = useCallback(() => { - setMaxAlerts(localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); - - setIsModalVisible(true); - }, [localStorageAttackDiscoveryMaxAlerts]); - const closeModal = useCallback(() => setIsModalVisible(false), []); - - const onReset = useCallback(() => setMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`), []); - - const onSave = useCallback(() => { - setLocalStorageAttackDiscoveryMaxAlerts(maxAlerts); - closeModal(); - }, [closeModal, maxAlerts, setLocalStorageAttackDiscoveryMaxAlerts]); - - const [showSettingsTour, setShowSettingsTour] = useLocalStorage<boolean>( - `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY}.v8.16`, - true - ); - const onTourFinished = useCallback(() => setShowSettingsTour(() => false), [setShowSettingsTour]); - const [tourDelayElapsed, setTourDelayElapsed] = useState(false); - - useEffect(() => { - // visible EuiTourStep anchors don't follow the button when the layout changes (i.e. when the connectors finish loading) - const timeout = setTimeout(() => setTourDelayElapsed(true), 10000); - return () => clearTimeout(timeout); - }, []); - - const onSettingsClicked = useCallback(() => { - showModal(); - setShowSettingsTour(() => false); - }, [setShowSettingsTour, showModal]); - - const SettingsButton = useMemo( - () => ( - <EuiToolTip content={i18n.SETTINGS}> - <EuiButtonIcon - aria-label={i18n.SETTINGS} - data-test-subj="settings" - iconType="gear" - onClick={onSettingsClicked} - /> - </EuiToolTip> - ), - [onSettingsClicked] - ); - - const isTourEnabled = getIsTourEnabled({ - connectorId, - isLoading, - tourDelayElapsed, - showSettingsTour, - }); - - return ( - <> - {isTourEnabled ? ( - <EuiTourStep - anchorPosition="downCenter" - content={ - <> - <EuiText size="s"> - <p> - <span>{i18n.ATTACK_DISCOVERY_SENDS_MORE_ALERTS}</span> - <br /> - <span>{i18n.CONFIGURE_YOUR_SETTINGS_HERE}</span> - </p> - </EuiText> - </> - } - isStepOpen={showSettingsTour} - minWidth={300} - onFinish={onTourFinished} - step={1} - stepsTotal={1} - subtitle={i18n.RECENT_ATTACK_DISCOVERY_IMPROVEMENTS} - title={i18n.SEND_MORE_ALERTS} - > - {SettingsButton} - </EuiTourStep> - ) : ( - <>{SettingsButton}</> - )} - - {isModalVisible && ( - <EuiModal aria-labelledby={modalTitleId} data-test-subj="modal" onClose={closeModal}> - <EuiModalHeader> - <EuiModalHeaderTitle id={modalTitleId}>{i18n.SETTINGS}</EuiModalHeaderTitle> - </EuiModalHeader> - - <EuiModalBody> - <AlertsSettings maxAlerts={maxAlerts} setMaxAlerts={setMaxAlerts} /> - </EuiModalBody> - - <EuiModalFooter> - <Footer closeModal={closeModal} onReset={onReset} onSave={onSave} /> - </EuiModalFooter> - </EuiModal> - )} - </> - ); -}; - -SettingsModalComponent.displayName = 'SettingsModal'; - -export const SettingsModal = React.memo(SettingsModalComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts deleted file mode 100644 index 7f2f356114902..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getIsTourEnabled = ({ - connectorId, - isLoading, - tourDelayElapsed, - showSettingsTour, -}: { - connectorId: string | undefined; - isLoading: boolean; - tourDelayElapsed: boolean; - showSettingsTour: boolean | undefined; -}): boolean => !isLoading && connectorId != null && tourDelayElapsed && !!showSettingsTour; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts deleted file mode 100644 index dc42db84f2d8a..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const ALERTS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.alertsLabel', - { - defaultMessage: 'Alerts', - } -); - -export const ATTACK_DISCOVERY_SENDS_MORE_ALERTS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.attackDiscoverySendsMoreAlertsTourText', - { - defaultMessage: 'Attack discovery sends more alerts as context.', - } -); - -export const CANCEL = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.cancelButton', - { - defaultMessage: 'Cancel', - } -); - -export const CONFIGURE_YOUR_SETTINGS_HERE = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.configureYourSettingsHereTourText', - { - defaultMessage: 'Configure your settings here.', - } -); - -export const LATEST_AND_RISKIEST_OPEN_ALERTS = (alertsCount: number) => - i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.latestAndRiskiestOpenAlertsLabel', - { - defaultMessage: - 'Send Attack discovery information about your {alertsCount} newest and riskiest open or acknowledged alerts.', - values: { alertsCount }, - } - ); - -export const SAVE = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.saveButton', - { - defaultMessage: 'Save', - } -); - -export const SEND_MORE_ALERTS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.tourTitle', - { - defaultMessage: 'Send more alerts', - } -); - -export const SETTINGS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.settingsLabel', - { - defaultMessage: 'Settings', - } -); - -export const RECENT_ATTACK_DISCOVERY_IMPROVEMENTS = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.tourSubtitle', - { - defaultMessage: 'Recent Attack discovery improvements', - } -); - -export const RESET = i18n.translate( - 'xpack.securitySolution.attackDiscovery.settingsModal.resetLabel', - { - defaultMessage: 'Reset', - } -); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts index c7e1c579418b4..e94687611ea8f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts @@ -12,7 +12,6 @@ describe('helpers', () => { it('returns true when isLoading is false and alertsContextCount is 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, - connectorId: 'test', isLoading: false, }); @@ -22,7 +21,6 @@ describe('helpers', () => { it('returns false when isLoading is true', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, - connectorId: 'test', isLoading: true, }); @@ -32,7 +30,6 @@ describe('helpers', () => { it('returns false when alertsContextCount is null', () => { const result = showNoAlertsPrompt({ alertsContextCount: null, - connectorId: 'test', isLoading: false, }); @@ -42,7 +39,6 @@ describe('helpers', () => { it('returns false when alertsContextCount greater than 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 20, - connectorId: 'test', isLoading: false, }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts index b990c3ccf1555..e3d3be963bacd 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts @@ -75,14 +75,11 @@ export const getErrorToastText = ( export const showNoAlertsPrompt = ({ alertsContextCount, - connectorId, isLoading, }: { alertsContextCount: number | null; - connectorId: string | undefined; isLoading: boolean; -}): boolean => - connectorId != null && !isLoading && alertsContextCount != null && alertsContextCount === 0; +}): boolean => !isLoading && alertsContextCount != null && alertsContextCount === 0; export const showWelcomePrompt = ({ aiConnectorsCount, @@ -114,26 +111,12 @@ export const showLoading = ({ loadingConnectorId: string | null; }): boolean => isLoading && (loadingConnectorId === connectorId || attackDiscoveriesCount === 0); -export const showSummary = (attackDiscoveriesCount: number) => attackDiscoveriesCount > 0; - -export const showFailurePrompt = ({ +export const showSummary = ({ connectorId, - failureReason, - isLoading, + attackDiscoveriesCount, + loadingConnectorId, }: { connectorId: string | undefined; - failureReason: string | null; - isLoading: boolean; -}): boolean => connectorId != null && !isLoading && failureReason != null; - -export const getSize = ({ - defaultMaxAlerts, - localStorageAttackDiscoveryMaxAlerts, -}: { - defaultMaxAlerts: number; - localStorageAttackDiscoveryMaxAlerts: string | undefined; -}): number => { - const size = Number(localStorageAttackDiscoveryMaxAlerts); - - return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; -}; + attackDiscoveriesCount: number; + loadingConnectorId: string | null; +}): boolean => loadingConnectorId !== connectorId && attackDiscoveriesCount > 0; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index e55b2fe5083b6..ea5c16fc3cbba 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -5,13 +5,11 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; import { css } from '@emotion/react'; import { ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, - DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, - MAX_ALERTS_LOCAL_STORAGE_KEY, useAssistantContext, useLoadConnectors, } from '@kbn/elastic-assistant'; @@ -25,16 +23,23 @@ import { HeaderPage } from '../../common/components/header_page'; import { useSpaceId } from '../../common/hooks/use_space_id'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Header } from './header'; -import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers'; +import { + CONNECTOR_ID_LOCAL_STORAGE_KEY, + getInitialIsOpen, + showLoading, + showSummary, +} from './helpers'; +import { AttackDiscoveryPanel } from '../attack_discovery_panel'; +import { EmptyStates } from './empty_states'; import { LoadingCallout } from './loading_callout'; import { PageTitle } from './page_title'; -import { Results } from './results'; +import { Summary } from './summary'; import { useAttackDiscovery } from '../use_attack_discovery'; const AttackDiscoveryPageComponent: React.FC = () => { const spaceId = useSpaceId() ?? 'default'; - const { http } = useAssistantContext(); + const { http, knowledgeBase } = useAssistantContext(); const { data: aiConnectors } = useLoadConnectors({ http, }); @@ -49,12 +54,6 @@ const AttackDiscoveryPageComponent: React.FC = () => { `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${CONNECTOR_ID_LOCAL_STORAGE_KEY}` ); - const [localStorageAttackDiscoveryMaxAlerts, setLocalStorageAttackDiscoveryMaxAlerts] = - useLocalStorage<string>( - `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${MAX_ALERTS_LOCAL_STORAGE_KEY}`, - `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` - ); - const [connectorId, setConnectorId] = React.useState<string | undefined>( localStorageAttackDiscoveryConnectorId ); @@ -79,10 +78,6 @@ const AttackDiscoveryPageComponent: React.FC = () => { } = useAttackDiscovery({ connectorId, setLoadingConnectorId, - size: getSize({ - defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, - localStorageAttackDiscoveryMaxAlerts, - }), }); // get last updated from the cached attack discoveries if it exists: @@ -164,11 +159,9 @@ const AttackDiscoveryPageComponent: React.FC = () => { isLoading={isLoading} // disable header actions before post request has completed isDisabledActions={isLoadingPost} - localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} onConnectorIdSelected={onConnectorIdSelected} onGenerate={onGenerate} onCancel={onCancel} - setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} stats={stats} /> <EuiSpacer size="m" /> @@ -177,37 +170,68 @@ const AttackDiscoveryPageComponent: React.FC = () => { <EuiEmptyPrompt data-test-subj="animatedLogo" icon={animatedLogo} /> ) : ( <> - {showLoading({ + {showSummary({ attackDiscoveriesCount, connectorId, - isLoading: isLoading || isLoadingPost, loadingConnectorId, - }) ? ( - <LoadingCallout - alertsContextCount={alertsContextCount} - localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} - approximateFutureTime={approximateFutureTime} - connectorIntervals={connectorIntervals} - /> - ) : ( - <Results - aiConnectorsCount={aiConnectors?.length ?? null} - alertsContextCount={alertsContextCount} + }) && ( + <Summary alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} - connectorId={connectorId} - failureReason={failureReason} - isLoading={isLoading} - isLoadingPost={isLoadingPost} - localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} - onGenerate={onGenerate} + lastUpdated={selectedConnectorLastUpdated} onToggleShowAnonymized={onToggleShowAnonymized} - selectedConnectorAttackDiscoveries={selectedConnectorAttackDiscoveries} - selectedConnectorLastUpdated={selectedConnectorLastUpdated} - selectedConnectorReplacements={selectedConnectorReplacements} showAnonymized={showAnonymized} /> )} + + <> + {showLoading({ + attackDiscoveriesCount, + connectorId, + isLoading: isLoading || isLoadingPost, + loadingConnectorId, + }) ? ( + <LoadingCallout + alertsCount={knowledgeBase.latestAlerts} + approximateFutureTime={approximateFutureTime} + connectorIntervals={connectorIntervals} + /> + ) : ( + selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( + <React.Fragment key={attackDiscovery.id}> + <AttackDiscoveryPanel + attackDiscovery={attackDiscovery} + initialIsOpen={getInitialIsOpen(i)} + showAnonymized={showAnonymized} + replacements={selectedConnectorReplacements} + /> + <EuiSpacer size="l" /> + </React.Fragment> + )) + )} + </> + <EuiFlexGroup + css={css` + max-height: 100%; + min-height: 100%; + `} + direction="column" + gutterSize="none" + > + <EuiSpacer size="xxl" /> + <EuiFlexItem grow={false}> + <EmptyStates + aiConnectorsCount={aiConnectors?.length ?? null} + alertsContextCount={alertsContextCount} + alertsCount={knowledgeBase.latestAlerts} + attackDiscoveriesCount={attackDiscoveriesCount} + failureReason={failureReason} + connectorId={connectorId} + isLoading={isLoading || isLoadingPost} + onGenerate={onGenerate} + /> + </EuiFlexItem> + </EuiFlexGroup> </> )} <SpyRoute pageName={SecurityPageName.attackDiscovery} /> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx index f755017288300..af6efafb3c1dd 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx @@ -29,10 +29,9 @@ describe('LoadingCallout', () => { ]; const defaultProps = { - alertsContextCount: 30, + alertsCount: 30, approximateFutureTime: new Date(), connectorIntervals, - localStorageAttackDiscoveryMaxAlerts: '50', }; it('renders the animated loading icon', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx index aee8241ec73fc..7e392e3165711 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx @@ -20,15 +20,13 @@ const BACKGROUND_COLOR_DARK = '#0B2030'; const BORDER_COLOR_DARK = '#0B2030'; interface Props { - alertsContextCount: number | null; + alertsCount: number; approximateFutureTime: Date | null; connectorIntervals: GenerationInterval[]; - localStorageAttackDiscoveryMaxAlerts: string | undefined; } const LoadingCalloutComponent: React.FC<Props> = ({ - alertsContextCount, - localStorageAttackDiscoveryMaxAlerts, + alertsCount, approximateFutureTime, connectorIntervals, }) => { @@ -48,14 +46,11 @@ const LoadingCalloutComponent: React.FC<Props> = ({ `} grow={false} > - <LoadingMessages - alertsContextCount={alertsContextCount} - localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} - /> + <LoadingMessages alertsCount={alertsCount} /> </EuiFlexItem> </EuiFlexGroup> ), - [alertsContextCount, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts] + [alertsCount, euiTheme.size.m] ); const isDarkMode = theme.getTheme().darkMode === true; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts deleted file mode 100644 index 9a3061272ca15..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getLoadingCalloutAlertsCount = ({ - alertsContextCount, - defaultMaxAlerts, - localStorageAttackDiscoveryMaxAlerts, -}: { - alertsContextCount: number | null; - defaultMaxAlerts: number; - localStorageAttackDiscoveryMaxAlerts: string | undefined; -}): number => { - if (alertsContextCount != null && !isNaN(alertsContextCount) && alertsContextCount > 0) { - return alertsContextCount; - } - - const size = Number(localStorageAttackDiscoveryMaxAlerts); - - return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; -}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx index 8b3f174792c5e..250a25055791a 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx @@ -16,7 +16,7 @@ describe('LoadingMessages', () => { it('renders the expected loading message', () => { render( <TestProviders> - <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> + <LoadingMessages alertsCount={20} /> </TestProviders> ); const attackDiscoveryGenerationInProgress = screen.getByTestId( @@ -31,7 +31,7 @@ describe('LoadingMessages', () => { it('renders the loading message with the expected alerts count', () => { render( <TestProviders> - <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> + <LoadingMessages alertsCount={20} /> </TestProviders> ); const aiCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing'); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx index 1a84771e5c635..9acd7b4d2dbbf 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx @@ -7,34 +7,22 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import React from 'react'; import { useKibana } from '../../../../common/lib/kibana'; -import { getLoadingCalloutAlertsCount } from './get_loading_callout_alerts_count'; import * as i18n from '../translations'; const TEXT_COLOR = '#343741'; interface Props { - alertsContextCount: number | null; - localStorageAttackDiscoveryMaxAlerts: string | undefined; + alertsCount: number; } -const LoadingMessagesComponent: React.FC<Props> = ({ - alertsContextCount, - localStorageAttackDiscoveryMaxAlerts, -}) => { +const LoadingMessagesComponent: React.FC<Props> = ({ alertsCount }) => { const { theme } = useKibana().services; const isDarkMode = theme.getTheme().darkMode === true; - const alertsCount = getLoadingCalloutAlertsCount({ - alertsContextCount, - defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, - localStorageAttackDiscoveryMaxAlerts, - }); - return ( <EuiFlexGroup data-test-subj="loadingMessages" direction="column" gutterSize="none"> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx index 6c6bbfb25cb7f..6c2640623e370 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx @@ -13,7 +13,7 @@ import { ATTACK_DISCOVERY_ONLY, LEARN_MORE, NO_ALERTS_TO_ANALYZE } from './trans describe('NoAlerts', () => { beforeEach(() => { - render(<NoAlerts isDisabled={false} isLoading={false} onGenerate={jest.fn()} />); + render(<NoAlerts />); }); it('renders the avatar', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx index ace75f568bf3d..a7b0cd929336b 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx @@ -17,15 +17,8 @@ import { import React, { useMemo } from 'react'; import * as i18n from './translations'; -import { Generate } from '../generate'; -interface Props { - isDisabled: boolean; - isLoading: boolean; - onGenerate: () => void; -} - -const NoAlertsComponent: React.FC<Props> = ({ isDisabled, isLoading, onGenerate }) => { +const NoAlertsComponent: React.FC = () => { const title = useMemo( () => ( <EuiFlexGroup @@ -90,14 +83,6 @@ const NoAlertsComponent: React.FC<Props> = ({ isDisabled, isLoading, onGenerate {i18n.LEARN_MORE} </EuiLink> </EuiFlexItem> - - <EuiFlexItem grow={false}> - <EuiSpacer size="m" /> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <Generate isDisabled={isDisabled} isLoading={isLoading} onGenerate={onGenerate} /> - </EuiFlexItem> </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx deleted file mode 100644 index 6e3e43127e711..0000000000000 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSpacer } from '@elastic/eui'; -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; -import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import React from 'react'; - -import { AttackDiscoveryPanel } from '../../attack_discovery_panel'; -import { EmptyStates } from '../empty_states'; -import { showEmptyStates } from '../empty_states/helpers/show_empty_states'; -import { getInitialIsOpen, showSummary } from '../helpers'; -import { Summary } from '../summary'; - -interface Props { - aiConnectorsCount: number | null; // null when connectors are not configured - alertsContextCount: number | null; // null when unavailable for the current connector - alertsCount: number; - attackDiscoveriesCount: number; - connectorId: string | undefined; - failureReason: string | null; - isLoading: boolean; - isLoadingPost: boolean; - localStorageAttackDiscoveryMaxAlerts: string | undefined; - onGenerate: () => Promise<void>; - onToggleShowAnonymized: () => void; - selectedConnectorAttackDiscoveries: AttackDiscovery[]; - selectedConnectorLastUpdated: Date | null; - selectedConnectorReplacements: Replacements; - showAnonymized: boolean; -} - -const ResultsComponent: React.FC<Props> = ({ - aiConnectorsCount, - alertsContextCount, - alertsCount, - attackDiscoveriesCount, - connectorId, - failureReason, - isLoading, - isLoadingPost, - localStorageAttackDiscoveryMaxAlerts, - onGenerate, - onToggleShowAnonymized, - selectedConnectorAttackDiscoveries, - selectedConnectorLastUpdated, - selectedConnectorReplacements, - showAnonymized, -}) => { - if ( - showEmptyStates({ - aiConnectorsCount, - alertsContextCount, - attackDiscoveriesCount, - connectorId, - failureReason, - isLoading, - }) - ) { - return ( - <> - <EuiSpacer size="xxl" /> - <EmptyStates - aiConnectorsCount={aiConnectorsCount} - alertsContextCount={alertsContextCount} - attackDiscoveriesCount={attackDiscoveriesCount} - failureReason={failureReason} - connectorId={connectorId} - isLoading={isLoading || isLoadingPost} - onGenerate={onGenerate} - upToAlertsCount={Number( - localStorageAttackDiscoveryMaxAlerts ?? DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS - )} - /> - </> - ); - } - - return ( - <> - {showSummary(attackDiscoveriesCount) && ( - <Summary - alertsCount={alertsCount} - attackDiscoveriesCount={attackDiscoveriesCount} - lastUpdated={selectedConnectorLastUpdated} - onToggleShowAnonymized={onToggleShowAnonymized} - showAnonymized={showAnonymized} - /> - )} - - {selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( - <React.Fragment key={attackDiscovery.id}> - <AttackDiscoveryPanel - attackDiscovery={attackDiscovery} - initialIsOpen={getInitialIsOpen(i)} - showAnonymized={showAnonymized} - replacements={selectedConnectorReplacements} - /> - <EuiSpacer size="l" /> - </React.Fragment> - ))} - </> - ); -}; - -ResultsComponent.displayName = 'Results'; - -export const Results = React.memo(ResultsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts index cc0034c90d1fa..f2fd17d5978b7 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { omit } from 'lodash/fp'; @@ -133,7 +132,9 @@ describe('getRequestBody', () => { }, ], }; - + const knowledgeBase = { + latestAlerts: 20, + }; const traceOptions = { apmUrl: '/app/apm', langSmithProject: '', @@ -144,7 +145,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + knowledgeBase, traceOptions, }); @@ -159,8 +160,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, replacements: {}, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -169,7 +170,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern: undefined, anonymizationFields, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + knowledgeBase, traceOptions, }); @@ -184,8 +185,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, replacements: {}, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -194,7 +195,7 @@ describe('getRequestBody', () => { const withLangSmith = { alertsIndexPattern, anonymizationFields, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + knowledgeBase, traceOptions: { apmUrl: '/app/apm', langSmithProject: 'A project', @@ -215,7 +216,7 @@ describe('getRequestBody', () => { }, langSmithApiKey: withLangSmith.traceOptions.langSmithApiKey, langSmithProject: withLangSmith.traceOptions.langSmithProject, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + size: knowledgeBase.latestAlerts, replacements: {}, subAction: 'invokeAI', }); @@ -225,8 +226,8 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, + knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -241,7 +242,7 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + size: knowledgeBase.latestAlerts, replacements: {}, subAction: 'invokeAI', }); @@ -257,8 +258,8 @@ describe('getRequestBody', () => { alertsIndexPattern, anonymizationFields, genAiConfig, // <-- genAiConfig is provided + knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -273,8 +274,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, replacements: {}, - size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts index 7aa9bfdd118d9..97eb132bdaaeb 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { TraceOptions } from '@kbn/elastic-assistant/impl/assistant/types'; +import type { + KnowledgeBaseConfig, + TraceOptions, +} from '@kbn/elastic-assistant/impl/assistant/types'; import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import type { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -57,8 +60,8 @@ export const getRequestBody = ({ alertsIndexPattern, anonymizationFields, genAiConfig, + knowledgeBase, selectedConnector, - size, traceOptions, }: { alertsIndexPattern: string | undefined; @@ -80,7 +83,7 @@ export const getRequestBody = ({ }>; }; genAiConfig?: GenAiConfig; - size: number; + knowledgeBase: KnowledgeBaseConfig; selectedConnector?: ActionConnector; traceOptions: TraceOptions; }): AttackDiscoveryPostRequestBody => ({ @@ -92,8 +95,8 @@ export const getRequestBody = ({ langSmithApiKey: isEmpty(traceOptions?.langSmithApiKey) ? undefined : traceOptions?.langSmithApiKey, + size: knowledgeBase.latestAlerts, replacements: {}, // no need to re-use replacements in the current implementation - size, subAction: 'invokeAI', // non-streaming apiConfig: { connectorId: selectedConnector?.id ?? '', diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx index 59659ee6d8649..6329ce5ca699a 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx @@ -106,8 +106,6 @@ const mockAttackDiscoveries = [ const setLoadingConnectorId = jest.fn(); const setStatus = jest.fn(); -const SIZE = 20; - describe('useAttackDiscovery', () => { const mockPollApi = { cancelAttackDiscovery: jest.fn(), @@ -128,11 +126,7 @@ describe('useAttackDiscovery', () => { it('initializes with correct default values', () => { const { result } = renderHook(() => - useAttackDiscovery({ - connectorId: 'test-id', - setLoadingConnectorId, - size: 20, - }) + useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) ); expect(result.current.alertsContextCount).toBeNull(); @@ -150,15 +144,14 @@ describe('useAttackDiscovery', () => { it('fetches attack discoveries and updates state correctly', async () => { (mockedUseKibana.services.http.fetch as jest.Mock).mockResolvedValue(mockAttackDiscoveryPost); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); - + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); await act(async () => { await result.current.fetchAttackDiscoveries(); }); expect(mockedUseKibana.services.http.fetch).toHaveBeenCalledWith( '/internal/elastic_assistant/attack_discovery', { - body: `{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"replacements":{},"size":${SIZE},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}`, + body: '{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"size":20,"replacements":{},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}', method: 'POST', version: '1', } @@ -174,7 +167,7 @@ describe('useAttackDiscovery', () => { const error = new Error(errorMessage); (mockedUseKibana.services.http.fetch as jest.Mock).mockRejectedValue(error); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); await act(async () => { await result.current.fetchAttackDiscoveries(); @@ -191,11 +184,7 @@ describe('useAttackDiscovery', () => { it('sets loading state based on poll status', async () => { (usePollApi as jest.Mock).mockReturnValue({ ...mockPollApi, status: 'running' }); const { result } = renderHook(() => - useAttackDiscovery({ - connectorId: 'test-id', - setLoadingConnectorId, - size: SIZE, - }) + useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) ); expect(result.current.isLoading).toBe(true); @@ -213,7 +202,7 @@ describe('useAttackDiscovery', () => { }, status: 'succeeded', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); expect(result.current.alertsContextCount).toEqual(20); // this is set from usePollApi @@ -238,7 +227,7 @@ describe('useAttackDiscovery', () => { }, status: 'failed', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); expect(result.current.failureReason).toEqual('something bad'); expect(result.current.isLoading).toBe(false); @@ -252,13 +241,7 @@ describe('useAttackDiscovery', () => { data: [], // <-- zero connectors configured }); - renderHook(() => - useAttackDiscovery({ - connectorId: 'test-id', - setLoadingConnectorId, - size: SIZE, - }) - ); + renderHook(() => useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId })); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx index 4ad78981d4540..deb1c556bdb43 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx @@ -43,11 +43,9 @@ export interface UseAttackDiscovery { export const useAttackDiscovery = ({ connectorId, - size, setLoadingConnectorId, }: { connectorId: string | undefined; - size: number; setLoadingConnectorId?: (loadingConnectorId: string | null) => void; }): UseAttackDiscovery => { // get Kibana services and connectors @@ -77,7 +75,7 @@ export const useAttackDiscovery = ({ const [isLoading, setIsLoading] = useState(false); // get alerts index pattern and allow lists from the assistant context: - const { alertsIndexPattern, traceOptions } = useAssistantContext(); + const { alertsIndexPattern, knowledgeBase, traceOptions } = useAssistantContext(); const { data: anonymizationFields } = useFetchAnonymizationFields(); @@ -97,11 +95,18 @@ export const useAttackDiscovery = ({ alertsIndexPattern, anonymizationFields, genAiConfig, - size, + knowledgeBase, selectedConnector, traceOptions, }); - }, [aiConnectors, alertsIndexPattern, anonymizationFields, connectorId, size, traceOptions]); + }, [ + aiConnectors, + alertsIndexPattern, + anonymizationFields, + connectorId, + knowledgeBase, + traceOptions, + ]); useEffect(() => { if ( @@ -135,7 +140,7 @@ export const useAttackDiscovery = ({ useEffect(() => { if (pollData !== null && pollData.connectorId === connectorId) { if (pollData.alertsContextCount != null) setAlertsContextCount(pollData.alertsContextCount); - if (pollData.attackDiscoveries.length && pollData.attackDiscoveries[0].timestamp != null) { + if (pollData.attackDiscoveries.length) { // get last updated from timestamp, not from updatedAt since this can indicate the last time the status was updated setLastUpdated(new Date(pollData.attackDiscoveries[0].timestamp)); } diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts new file mode 100644 index 0000000000000..4d06751f57d7d --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts @@ -0,0 +1,340 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import type { DynamicTool } from '@langchain/core/tools'; + +import { loggerMock } from '@kbn/logging-mocks'; + +import { ATTACK_DISCOVERY_TOOL } from './attack_discovery_tool'; +import { mockAnonymizationFields } from '../mock/mock_anonymization_fields'; +import { mockEmptyOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_empty_open_and_acknowledged_alerts_qery_results'; +import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; + +jest.mock('langchain/chains', () => { + const mockLLMChain = jest.fn().mockImplementation(() => ({ + call: jest.fn().mockResolvedValue({ + records: [ + { + alertIds: [ + 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', + '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', + 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', + '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', + ], + detailsMarkdown: + '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', + entitySummaryMarkdown: + 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', + mitreAttackTactics: ['Credential Access', 'Execution'], + summaryMarkdown: + 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', + title: 'Malware Delivering Malicious Payloads on macOS', + }, + ], + }), + })); + + return { + LLMChain: mockLLMChain, + }; +}); + +describe('AttackDiscoveryTool', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const replacements = { uuid: 'original_value' }; + const size = 20; + const request = { + body: { + actionTypeId: '.bedrock', + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + connectorId: 'test-connector-id', + replacements, + size, + subAction: 'invokeAI', + }, + } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; + + const esClient = { + search: jest.fn(), + } as unknown as ElasticsearchClient; + const llm = jest.fn() as unknown as ActionsClientLlm; + const logger = loggerMock.create(); + + const rest = { + anonymizationFields: mockAnonymizationFields, + isEnabledKnowledgeBase: false, + llm, + logger, + onNewReplacements: jest.fn(), + size, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + (esClient.search as jest.Mock).mockResolvedValue(mockOpenAndAcknowledgedAlertsQueryResults); + }); + + describe('isSupported', () => { + it('returns false when the request is missing required anonymization parameters', () => { + const requestMissingAnonymizationParams = { + body: { + isEnabledKnowledgeBase: false, + alertsIndexPattern: '.alerts-security.alerts-default', + size: 20, + }, + } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; + + const params = { + alertsIndexPattern, + esClient, + request: requestMissingAnonymizationParams, // <-- request is missing required anonymization parameters + ...rest, + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); + }); + + it('returns false when the alertsIndexPattern is undefined', () => { + const params = { + esClient, + request, + ...rest, + alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); + }); + + it('returns false when size is undefined', () => { + const params = { + alertsIndexPattern, + esClient, + request, + ...rest, + size: undefined, // <-- size is undefined + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); + }); + + it('returns false when size is out of range', () => { + const params = { + alertsIndexPattern, + esClient, + request, + ...rest, + size: 0, // <-- size is out of range + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); + }); + + it('returns false when llm is undefined', () => { + const params = { + alertsIndexPattern, + esClient, + request, + ...rest, + llm: undefined, // <-- llm is undefined + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); + }); + + it('returns true if all required params are provided', () => { + const params = { + alertsIndexPattern, + esClient, + request, + ...rest, + }; + + expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(true); + }); + }); + + describe('getTool', () => { + it('returns null when llm is undefined', () => { + const tool = ATTACK_DISCOVERY_TOOL.getTool({ + alertsIndexPattern, + esClient, + replacements, + request, + ...rest, + llm: undefined, // <-- llm is undefined + }); + + expect(tool).toBeNull(); + }); + + it('returns a `DynamicTool` with a `func` that calls `esClient.search()` with the expected alerts query', async () => { + const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ + alertsIndexPattern, + esClient, + replacements, + request, + ...rest, + }) as DynamicTool; + + await tool.func(''); + + expect(esClient.search).toHaveBeenCalledWith({ + allow_no_indices: true, + body: { + _source: false, + fields: mockAnonymizationFields.map(({ field }) => ({ + field, + include_unmapped: true, + })), + query: { + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'kibana.alert.workflow_status': 'open', + }, + }, + { + match_phrase: { + 'kibana.alert.workflow_status': 'acknowledged', + }, + }, + ], + }, + }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: 'now-24h', + lte: 'now', + }, + }, + }, + ], + must: [], + must_not: [ + { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, + ], + should: [], + }, + }, + ], + }, + }, + runtime_mappings: {}, + size, + sort: [ + { + 'kibana.alert.risk_score': { + order: 'desc', + }, + }, + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + ignore_unavailable: true, + index: [alertsIndexPattern], + }); + }); + + it('returns a `DynamicTool` with a `func` returns an empty attack discoveries array when getAnonymizedAlerts returns no alerts', async () => { + (esClient.search as jest.Mock).mockResolvedValue( + mockEmptyOpenAndAcknowledgedAlertsQueryResults // <-- no alerts + ); + + const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ + alertsIndexPattern, + esClient, + replacements, + request, + ...rest, + }) as DynamicTool; + + const result = await tool.func(''); + const expected = JSON.stringify({ alertsContextCount: 0, attackDiscoveries: [] }, null, 2); // <-- empty attack discoveries array + + expect(result).toEqual(expected); + }); + + it('returns a `DynamicTool` with a `func` that returns the expected results', async () => { + const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ + alertsIndexPattern, + esClient, + replacements, + request, + ...rest, + }) as DynamicTool; + + await tool.func(''); + + const result = await tool.func(''); + const expected = JSON.stringify( + { + alertsContextCount: 20, + attackDiscoveries: [ + { + alertIds: [ + 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', + '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', + 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', + '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', + ], + detailsMarkdown: + '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', + entitySummaryMarkdown: + 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', + mitreAttackTactics: ['Credential Access', 'Execution'], + summaryMarkdown: + 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', + title: 'Malware Delivering Malicious Payloads on macOS', + }, + ], + }, + null, + 2 + ); + + expect(result).toEqual(expected); + }); + + it('returns a tool instance with the expected tags', () => { + const tool = ATTACK_DISCOVERY_TOOL.getTool({ + alertsIndexPattern, + esClient, + replacements, + request, + ...rest, + }) as DynamicTool; + + expect(tool.tags).toEqual(['attack-discovery']); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts new file mode 100644 index 0000000000000..264862d76b8f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PromptTemplate } from '@langchain/core/prompts'; +import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; +import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; +import { LLMChain } from 'langchain/chains'; +import { OutputFixingParser } from 'langchain/output_parsers'; +import { DynamicTool } from '@langchain/core/tools'; + +import { APP_UI_ID } from '../../../../common'; +import { getAnonymizedAlerts } from './get_anonymized_alerts'; +import { getOutputParser } from './get_output_parser'; +import { sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; +import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; + +export interface AttackDiscoveryToolParams extends AssistantToolParams { + alertsIndexPattern: string; + size: number; +} + +export const ATTACK_DISCOVERY_TOOL_DESCRIPTION = + 'Call this for attack discoveries containing `markdown` that should be displayed verbatim (with no additional processing).'; + +/** + * Returns a tool for generating attack discoveries from open and acknowledged + * alerts, or null if the request doesn't have all the required parameters. + */ +export const ATTACK_DISCOVERY_TOOL: AssistantTool = { + id: 'attack-discovery', + name: 'AttackDiscoveryTool', + description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, + sourceRegister: APP_UI_ID, + isSupported: (params: AssistantToolParams): params is AttackDiscoveryToolParams => { + const { alertsIndexPattern, llm, request, size } = params; + + return ( + requestHasRequiredAnonymizationParams(request) && + alertsIndexPattern != null && + size != null && + !sizeIsOutOfRange(size) && + llm != null + ); + }, + getTool(params: AssistantToolParams) { + if (!this.isSupported(params)) return null; + + const { + alertsIndexPattern, + anonymizationFields, + esClient, + langChainTimeout, + llm, + onNewReplacements, + replacements, + size, + } = params as AttackDiscoveryToolParams; + + return new DynamicTool({ + name: 'AttackDiscoveryTool', + description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, + func: async () => { + if (llm == null) { + throw new Error('LLM is required for attack discoveries'); + } + + const anonymizedAlerts = await getAnonymizedAlerts({ + alertsIndexPattern, + anonymizationFields, + esClient, + onNewReplacements, + replacements, + size, + }); + + const alertsContextCount = anonymizedAlerts.length; + if (alertsContextCount === 0) { + // No alerts to analyze, so return an empty attack discoveries array + return JSON.stringify({ alertsContextCount, attackDiscoveries: [] }, null, 2); + } + + const outputParser = getOutputParser(); + const outputFixingParser = OutputFixingParser.fromLLM(llm, outputParser); + + const prompt = new PromptTemplate({ + template: `Answer the user's question as best you can:\n{format_instructions}\n{query}`, + inputVariables: ['query'], + partialVariables: { + format_instructions: outputFixingParser.getFormatInstructions(), + }, + }); + + const answerFormattingChain = new LLMChain({ + llm, + prompt, + outputKey: 'records', + outputParser: outputFixingParser, + }); + + const result = await answerFormattingChain.call({ + query: getAttackDiscoveryPrompt({ anonymizedAlerts }), + timeout: langChainTimeout, + }); + const attackDiscoveries = result.records; + + return JSON.stringify({ alertsContextCount, attackDiscoveries }, null, 2); + }, + tags: ['attack-discovery'], + }); + }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts similarity index 90% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts rename to x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts index b616c392ddd21..6b7526870eb9f 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts @@ -6,19 +6,19 @@ */ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { getOpenAndAcknowledgedAlertsQuery } from '@kbn/elastic-assistant-common'; -const MIN_SIZE = 10; +import { getAnonymizedAlerts } from './get_anonymized_alerts'; +import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; +import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; +import { MIN_SIZE } from '../open_and_acknowledged_alerts/helpers'; -import { getAnonymizedAlerts } from '.'; -import { mockOpenAndAcknowledgedAlertsQueryResults } from '../../../../mock/mock_open_and_acknowledged_alerts_query_results'; - -jest.mock('@kbn/elastic-assistant-common', () => { - const original = jest.requireActual('@kbn/elastic-assistant-common'); +jest.mock('../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query', () => { + const original = jest.requireActual( + '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query' + ); return { - ...original, - getOpenAndAcknowledgedAlertsQuery: jest.fn(), + getOpenAndAcknowledgedAlertsQuery: jest.fn(() => original), }; }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts similarity index 77% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts rename to x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts index bc2a7f5bf9e71..5989caf439518 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts @@ -7,16 +7,12 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ElasticsearchClient } from '@kbn/core/server'; -import { - Replacements, - getAnonymizedValue, - getOpenAndAcknowledgedAlertsQuery, - getRawDataOrDefault, - sizeIsOutOfRange, - transformRawData, -} from '@kbn/elastic-assistant-common'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; +import { getRawDataOrDefault, sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; export const getAnonymizedAlerts = async ({ alertsIndexPattern, diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts similarity index 70% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts rename to x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts index 287f5e6b2130a..bc290bf172382 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts @@ -4,17 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; -import { getAlertsContextPrompt } from '.'; -import { getDefaultAttackDiscoveryPrompt } from '../../../helpers/get_default_attack_discovery_prompt'; - -describe('getAlertsContextPrompt', () => { - it('generates the correct prompt', () => { +describe('getAttackDiscoveryPrompt', () => { + it('should generate the correct attack discovery prompt', () => { const anonymizedAlerts = ['Alert 1', 'Alert 2', 'Alert 3']; - const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds). + const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. -Use context from the following alerts to provide insights: +Use context from the following open and acknowledged alerts to provide insights: """ Alert 1 @@ -25,10 +23,7 @@ Alert 3 """ `; - const prompt = getAlertsContextPrompt({ - anonymizedAlerts, - attackDiscoveryPrompt: getDefaultAttackDiscoveryPrompt(), - }); + const prompt = getAttackDiscoveryPrompt({ anonymizedAlerts }); expect(prompt).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts new file mode 100644 index 0000000000000..df211f0bd0a7d --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. +export const getAttackDiscoveryPrompt = ({ + anonymizedAlerts, +}: { + anonymizedAlerts: string[]; +}) => `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. + +Use context from the following open and acknowledged alerts to provide insights: + +""" +${anonymizedAlerts.join('\n\n')} +""" +`; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts new file mode 100644 index 0000000000000..5ad2cd11f817a --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getOutputParser } from './get_output_parser'; + +describe('getOutputParser', () => { + it('returns a structured output parser with the expected format instructions', () => { + const outputParser = getOutputParser(); + + const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. + +\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. + +For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} +would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. +Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. + +Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! + +Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: +\`\`\`json +{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\",\"$schema\":\"http://json-schema.org/draft-07/schema#\"} +\`\`\` +`; + + expect(outputParser.getFormatInstructions()).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts new file mode 100644 index 0000000000000..3d66257f060e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StructuredOutputParser } from 'langchain/output_parsers'; +import { z } from '@kbn/zod'; + +export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; +const GOOD_SYNTAX_EXAMPLES = + 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; + +const BAD_SYNTAX_EXAMPLES = + 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; + +const RECONNAISSANCE = 'Reconnaissance'; +const INITIAL_ACCESS = 'Initial Access'; +const EXECUTION = 'Execution'; +const PERSISTENCE = 'Persistence'; +const PRIVILEGE_ESCALATION = 'Privilege Escalation'; +const DISCOVERY = 'Discovery'; +const LATERAL_MOVEMENT = 'Lateral Movement'; +const COMMAND_AND_CONTROL = 'Command and Control'; +const EXFILTRATION = 'Exfiltration'; + +const MITRE_ATTACK_TACTICS = [ + RECONNAISSANCE, + INITIAL_ACCESS, + EXECUTION, + PERSISTENCE, + PRIVILEGE_ESCALATION, + DISCOVERY, + LATERAL_MOVEMENT, + COMMAND_AND_CONTROL, + EXFILTRATION, +] as const; + +// NOTE: we ask the LLM for `insight`s. We do NOT use the feature name, `AttackDiscovery`, in the prompt. +export const getOutputParser = () => + StructuredOutputParser.fromZodSchema( + z + .array( + z.object({ + alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), + detailsMarkdown: z + .string() + .describe( + `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), + entitySummaryMarkdown: z + .string() + .optional() + .describe( + `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` + ), + mitreAttackTactics: z + .string() + .array() + .optional() + .describe( + `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( + ',' + )}` + ), + summaryMarkdown: z + .string() + .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), + title: z + .string() + .describe( + 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' + ), + }) + ) + .describe( + `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ) + ); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/index.ts index 1b6e90eb7280f..a704aaa44d0a1 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/index.ts @@ -10,6 +10,7 @@ import type { AssistantTool } from '@kbn/elastic-assistant-plugin/server'; import { NL_TO_ESQL_TOOL } from './esql/nl_to_esql_tool'; import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool'; +import { ATTACK_DISCOVERY_TOOL } from './attack_discovery/attack_discovery_tool'; import { KNOWLEDGE_BASE_RETRIEVAL_TOOL } from './knowledge_base/knowledge_base_retrieval_tool'; import { KNOWLEDGE_BASE_WRITE_TOOL } from './knowledge_base/knowledge_base_write_tool'; import { SECURITY_LABS_KNOWLEDGE_BASE_TOOL } from './security_labs/security_labs_tool'; @@ -21,6 +22,7 @@ export const getAssistantTools = ({ }): AssistantTool[] => { const tools = [ ALERT_COUNTS_TOOL, + ATTACK_DISCOVERY_TOOL, NL_TO_ESQL_TOOL, KNOWLEDGE_BASE_RETRIEVAL_TOOL, KNOWLEDGE_BASE_WRITE_TOOL, diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts b/x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts similarity index 100% rename from x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts rename to x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts similarity index 96% rename from x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts rename to x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts index 975896f381443..c8b52779d7b42 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getOpenAndAcknowledgedAlertsQuery } from '.'; +import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; describe('getOpenAndAcknowledgedAlertsQuery', () => { it('returns the expected query', () => { diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts similarity index 87% rename from x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts rename to x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts index 6f6e196053ca6..4090e71baa371 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts @@ -5,13 +5,8 @@ * 2.0. */ -import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; -/** - * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. - * - * The alerts are ordered by risk score, and then from the most recent to the oldest. - */ export const getOpenAndAcknowledgedAlertsQuery = ({ alertsIndexPattern, anonymizationFields, diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts new file mode 100644 index 0000000000000..722936a368b36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getRawDataOrDefault, + isRawDataValid, + MAX_SIZE, + MIN_SIZE, + sizeIsOutOfRange, +} from './helpers'; + +describe('helpers', () => { + describe('isRawDataValid', () => { + it('returns true for valid raw data', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns true when a field array is empty', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + field3: [], // the Fields API may return an empty array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when a field does not have an array of values', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(isRawDataValid(rawData)).toBe(false); + }); + + it('returns true for empty raw data', () => { + const rawData = {}; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when raw data is an unexpected type', () => { + const rawData = 1234; + + // @ts-expect-error + expect(isRawDataValid(rawData)).toBe(false); + }); + }); + + describe('getRawDataOrDefault', () => { + it('returns the raw data when it is valid', () => { + const rawData = { + field1: [1, 2, 3], + field2: ['a', 'b', 'c'], + }; + + expect(getRawDataOrDefault(rawData)).toEqual(rawData); + }); + + it('returns an empty object when the raw data is invalid', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(getRawDataOrDefault(rawData)).toEqual({}); + }); + }); + + describe('sizeIsOutOfRange', () => { + it('returns true when size is undefined', () => { + const size = undefined; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is less than MIN_SIZE', () => { + const size = MIN_SIZE - 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is greater than MAX_SIZE', () => { + const size = MAX_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns false when size is exactly MIN_SIZE', () => { + const size = MIN_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is exactly MAX_SIZE', () => { + const size = MAX_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is within the valid range', () => { + const size = MIN_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts new file mode 100644 index 0000000000000..dcb30e04e9dbc --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +export const MIN_SIZE = 10; +export const MAX_SIZE = 10000; + +export type MaybeRawData = SearchResponse['fields'] | undefined; // note: this is the type of the "fields" property in the ES response + +export const isRawDataValid = (rawData: MaybeRawData): rawData is Record<string, unknown[]> => + typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); + +export const getRawDataOrDefault = (rawData: MaybeRawData): Record<string, unknown[]> => + isRawDataValid(rawData) ? rawData : {}; + +export const sizeIsOutOfRange = (size?: number): boolean => + size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts index 45587b65f5f4c..09bae1639f1b1 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts @@ -10,13 +10,12 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { DynamicTool } from '@langchain/core/tools'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts_tool'; +import { MAX_SIZE } from './helpers'; import type { RetrievalQAChain } from 'langchain/chains'; import { mockAlertsFieldsApi } from '@kbn/elastic-assistant-plugin/server/__mocks__/alerts'; import type { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen'; import { loggerMock } from '@kbn/logging-mocks'; -const MAX_SIZE = 10000; - describe('OpenAndAcknowledgedAlertsTool', () => { const alertsIndexPattern = 'alerts-index'; const esClient = { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts index cab015183f4a2..d6b0ad58d8adb 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts @@ -7,17 +7,13 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { Replacements } from '@kbn/elastic-assistant-common'; -import { - getAnonymizedValue, - getOpenAndAcknowledgedAlertsQuery, - getRawDataOrDefault, - sizeIsOutOfRange, - transformRawData, -} from '@kbn/elastic-assistant-common'; +import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; import { z } from '@kbn/zod'; import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; +import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; +import { getRawDataOrDefault, sizeIsOutOfRange } from './helpers'; import { APP_UI_ID } from '../../../../common'; export interface OpenAndAcknowledgedAlertsToolParams extends AssistantToolParams { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index ce79bd061548f..0d369f3c620c4 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -205,6 +205,7 @@ "@kbn/search-types", "@kbn/field-utils", "@kbn/core-saved-objects-api-server-mocks", + "@kbn/langchain", "@kbn/core-analytics-browser", "@kbn/core-i18n-browser", "@kbn/core-theme-browser", From ceea2ce6a52b1283b31c9342468570844d286f06 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita <nikita.khristinin@elastic.co> Date: Tue, 15 Oct 2024 20:59:48 +0200 Subject: [PATCH 09/31] Use internal user to create list (#196341) Recently there was changes which restrict creation of dot notation indices for not operator user in serverless. We created `.list-${space}` from the current user, by making API request from UI which is failing right now This is quick fix, which use internal user to create lists. Currently this check available only on serverless QA, but there is a plan to ship it to prod. Which will block the serverless release, as all tests failed. We checked on QA env, that with main branch we can't create those indices, but with this PR deployed, it fix it. --- x-pack/plugins/lists/server/plugin.ts | 9 +++++++- .../list_index/create_list_index_route.ts | 4 ++-- .../routes/utils/get_internal_list_client.ts | 21 +++++++++++++++++++ .../lists/server/routes/utils/index.ts | 1 + x-pack/plugins/lists/server/types.ts | 1 + .../routes/__mocks__/request_context.ts | 1 + 6 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/lists/server/routes/utils/get_internal_list_client.ts diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index 5878eb45adfa5..e51be74ec21fd 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -103,7 +103,7 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, security, savedObjects: { client: savedObjectsClient }, elasticsearch: { - client: { asCurrentUser: esClient }, + client: { asCurrentUser: esClient, asInternalUser: internalEsClient }, }, } = await context.core; if (config == null) { @@ -121,6 +121,13 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, }), getExtensionPointClient: (): ExtensionPointStorageClientInterface => extensionPoints.getClient(), + getInternalListClient: (): ListClient => + new ListClient({ + config, + esClient: internalEsClient, + spaceId, + user, + }), getListClient: (): ListClient => new ListClient({ config, diff --git a/x-pack/plugins/lists/server/routes/list_index/create_list_index_route.ts b/x-pack/plugins/lists/server/routes/list_index/create_list_index_route.ts index 2f74871f23fc2..5842d7032a8bc 100644 --- a/x-pack/plugins/lists/server/routes/list_index/create_list_index_route.ts +++ b/x-pack/plugins/lists/server/routes/list_index/create_list_index_route.ts @@ -11,7 +11,7 @@ import { CreateListIndexResponse } from '@kbn/securitysolution-lists-common/api' import type { ListsPluginRouter } from '../../types'; import { buildSiemResponse } from '../utils'; -import { getListClient } from '..'; +import { getInternalListClient } from '..'; export const createListIndexRoute = (router: ListsPluginRouter): void => { router.versioned @@ -26,7 +26,7 @@ export const createListIndexRoute = (router: ListsPluginRouter): void => { const siemResponse = buildSiemResponse(response); try { - const lists = await getListClient(context); + const lists = await getInternalListClient(context); const listDataStreamExists = await lists.getListDataStreamExists(); const listItemDataStreamExists = await lists.getListItemDataStreamExists(); diff --git a/x-pack/plugins/lists/server/routes/utils/get_internal_list_client.ts b/x-pack/plugins/lists/server/routes/utils/get_internal_list_client.ts new file mode 100644 index 0000000000000..8e81ad5013fe1 --- /dev/null +++ b/x-pack/plugins/lists/server/routes/utils/get_internal_list_client.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ListClient } from '../../services/lists/list_client'; +import { ErrorWithStatusCode } from '../../error_with_status_code'; +import type { ListsRequestHandlerContext } from '../../types'; + +export const getInternalListClient = async ( + context: ListsRequestHandlerContext +): Promise<ListClient> => { + const lists = (await context.lists)?.getInternalListClient(); + if (lists == null) { + throw new ErrorWithStatusCode('Lists is not found as a plugin', 404); + } else { + return lists; + } +}; diff --git a/x-pack/plugins/lists/server/routes/utils/index.ts b/x-pack/plugins/lists/server/routes/utils/index.ts index f035ae5dbfe9b..03966adf3df43 100644 --- a/x-pack/plugins/lists/server/routes/utils/index.ts +++ b/x-pack/plugins/lists/server/routes/utils/index.ts @@ -8,6 +8,7 @@ export * from './get_error_message_exception_list_item'; export * from './get_error_message_exception_list'; export * from './get_list_client'; +export * from './get_internal_list_client'; export * from './get_exception_list_client'; export * from './route_validation'; export * from './build_siem_response'; diff --git a/x-pack/plugins/lists/server/types.ts b/x-pack/plugins/lists/server/types.ts index 78fdd0e8534a6..e3b277c693412 100644 --- a/x-pack/plugins/lists/server/types.ts +++ b/x-pack/plugins/lists/server/types.ts @@ -53,6 +53,7 @@ export interface ListPluginSetup { * @public */ export interface ListsApiRequestHandlerContext { + getInternalListClient: () => ListClient; getListClient: () => ListClient; getExceptionListClient: () => ExceptionListClient; getExtensionPointClient: () => ExtensionPointStorageClientInterface; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index a5e0c8c60b1fc..f562ea7f7bf5f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -107,6 +107,7 @@ const createRequestContextMock = ( getListClient: jest.fn(() => clients.lists.listClient), getExceptionListClient: jest.fn(() => clients.lists.exceptionListClient), getExtensionPointClient: jest.fn(), + getInternalListClient: jest.fn(), }, }; }; From 7217b51452a089b808142ade04da8dafb01c180b Mon Sep 17 00:00:00 2001 From: Nick Peihl <nick.peihl@elastic.co> Date: Tue, 15 Oct 2024 15:05:24 -0400 Subject: [PATCH 10/31] [Canvas] Fix unescaped backslashes (#196311) Fixes unescaped backslashes in Canvas autocomplete --- .../public/components/expression_input/autocomplete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/presentation_util/public/components/expression_input/autocomplete.ts b/src/plugins/presentation_util/public/components/expression_input/autocomplete.ts index 16d0e10127403..ae317c48dd87b 100644 --- a/src/plugins/presentation_util/public/components/expression_input/autocomplete.ts +++ b/src/plugins/presentation_util/public/components/expression_input/autocomplete.ts @@ -439,7 +439,7 @@ function maybeQuote(value: any) { if (value.match(/^\{.*\}$/)) { return value; } - return `"${value.replace(/"/g, '\\"')}"`; + return `"${value.replace(/[\\"]/g, '\\$&')}"`; } return value; } From a63b93976c34a8f9bb78f0426ee52ef533d73712 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet <nicolas.chaulet@elastic.co> Date: Tue, 15 Oct 2024 15:18:43 -0400 Subject: [PATCH 11/31] [Fleet] Add placeholder and comments to integration config (#195735) --- package.json | 1 + src/dev/build/tasks/clean_tasks.ts | 1 + src/dev/yarn_deduplicate/index.ts | 2 +- .../epm/packages/__fixtures__/logs_2_3_0.ts | 136 ++++++++++ .../redis_1_18_0_package_info.json | 245 ++++++++++++++++++ .../redis_1_18_0_streams_template.ts | 81 ++++++ .../get_templates_inputs.test.ts.snap | 56 ++++ .../epm/packages/get_template_inputs.ts | 226 ++++++++++++++-- .../epm/packages/get_templates_inputs.test.ts | 57 +++- .../apis/epm/get_templates_inputs.ts | 13 +- yarn.lock | 5 + 11 files changed, 799 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/logs_2_3_0.ts create mode 100644 x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json create mode 100644 x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts create mode 100644 x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap diff --git a/package.json b/package.json index d8a97951b897d..0a7c0d6936d0a 100644 --- a/package.json +++ b/package.json @@ -1292,6 +1292,7 @@ "xstate": "^4.38.2", "xstate5": "npm:xstate@^5.18.1", "xterm": "^5.1.0", + "yaml": "^2.5.1", "yauzl": "^2.10.0", "yazl": "^2.5.1", "zod": "^3.22.3" diff --git a/src/dev/build/tasks/clean_tasks.ts b/src/dev/build/tasks/clean_tasks.ts index ad8eeaadaad60..19af3954fde45 100644 --- a/src/dev/build/tasks/clean_tasks.ts +++ b/src/dev/build/tasks/clean_tasks.ts @@ -56,6 +56,7 @@ export const CleanExtraFilesFromModules: Task = { // docs '**/doc', + '!**/yaml/dist/**/doc', // yaml package store code under doc https://github.com/eemeli/yaml/issues/384 '**/docs', '**/README', '**/CONTRIBUTING.md', diff --git a/src/dev/yarn_deduplicate/index.ts b/src/dev/yarn_deduplicate/index.ts index 3f942252e39ab..f95ee583fba01 100644 --- a/src/dev/yarn_deduplicate/index.ts +++ b/src/dev/yarn_deduplicate/index.ts @@ -17,7 +17,7 @@ const yarnLock = readFileSync(yarnLockFile, 'utf-8'); const output = fixDuplicates(yarnLock, { useMostCommon: false, excludeScopes: ['@types'], - excludePackages: ['axe-core', '@babel/types', 'csstype'], + excludePackages: ['axe-core', '@babel/types', 'csstype', 'yaml'], }); writeFileSync(yarnLockFile, output); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/logs_2_3_0.ts b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/logs_2_3_0.ts new file mode 100644 index 0000000000000..abbf60400271e --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/logs_2_3_0.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LOGS_2_3_0_PACKAGE_INFO = { + name: 'log', + version: '2.3.0', + title: 'Custom Logs', + owner: { github: 'elastic/elastic-agent-data-plane' }, + type: 'input', + categories: ['custom', 'custom_logs'], + conditions: { 'kibana.version': '^8.8.0' }, + icons: [{ src: '/img/icon.svg', type: 'image/svg+xml' }], + policy_templates: [ + { + name: 'logs', + title: 'Custom log file', + description: 'Collect your custom log files.', + multiple: true, + input: 'logfile', + type: 'logs', + template_path: 'input.yml.hbs', + vars: [ + { + name: 'paths', + required: true, + title: 'Log file path', + description: 'Path to log files to be collected', + type: 'text', + multi: true, + }, + { + name: 'exclude_files', + required: false, + show_user: false, + title: 'Exclude files', + description: 'Patterns to be ignored', + type: 'text', + multi: true, + }, + { + name: 'ignore_older', + type: 'text', + title: 'Ignore events older than', + default: '72h', + required: false, + show_user: false, + description: + 'If this option is specified, events that are older than the specified amount of time are ignored. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".', + }, + { + name: 'data_stream.dataset', + required: true, + title: 'Dataset name', + description: + "Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use `-` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html).\n", + type: 'text', + }, + { + name: 'tags', + type: 'text', + title: 'Tags', + description: 'Tags to include in the published event', + multi: true, + show_user: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + multi: false, + required: false, + show_user: false, + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.', + }, + { + name: 'custom', + title: 'Custom configurations', + description: + 'Here YAML configuration options can be used to be added to your configuration. Be careful using this as it might break your configuration file.\n', + type: 'yaml', + default: '', + }, + ], + }, + ], + elasticsearch: {}, + description: 'Collect custom logs with Elastic Agent.', + format_version: '2.6.0', + readme: '/package/log/2.3.0/docs/README.md', + release: 'ga', + latestVersion: '2.3.2', + assets: {}, + licensePath: '/package/log/2.3.0/LICENSE.txt', + keepPoliciesUpToDate: false, + status: 'not_installed', +}; + +export const LOGS_2_3_0_ASSETS_MAP = new Map([ + [ + 'log-2.3.0/agent/input/input.yml.hbs', + Buffer.from(`paths: +{{#each paths}} + - {{this}} +{{/each}} + +{{#if exclude_files}} +exclude_files: +{{#each exclude_files}} + - {{this}} +{{/each}} +{{/if}} +{{#if ignore_older}} +ignore_older: {{ignore_older}} +{{/if}} +data_stream: + dataset: {{data_stream.dataset}} +{{#if processors.length}} +processors: +{{processors}} +{{/if}} +{{#if tags.length}} +tags: +{{#each tags as |tag i|}} +- {{tag}} +{{/each}} +{{/if}} + +{{custom}} +`), + ], +]); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json new file mode 100644 index 0000000000000..57c9b0c68fac9 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_package_info.json @@ -0,0 +1,245 @@ +{ + "name": "redis", + "title": "Redis", + "version": "1.18.0", + "release": "ga", + "description": "Collect logs and metrics from Redis servers with Elastic Agent.", + "type": "integration", + "download": "/epr/redis/redis-1.18.0.zip", + "path": "/package/redis/1.18.0", + "icons": [ + { + "src": "/img/logo_redis.svg", + "path": "/package/redis/1.18.0/img/logo_redis.svg", + "title": "logo redis", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "conditions": { + "kibana": { + "version": "^8.13.0" + }, + "elastic": { + "subscription": "basic" + } + }, + "owner": { + "type": "elastic", + "github": "elastic/obs-infraobs-integrations" + }, + "categories": ["datastore", "observability"], + "signature_path": "/epr/redis/redis-1.18.0.zip.sig", + "format_version": "3.0.2", + "readme": "/package/redis/1.18.0/docs/README.md", + "license": "basic", + "screenshots": [ + { + "src": "/img/kibana-redis.png", + "path": "/package/redis/1.18.0/img/kibana-redis.png", + "title": "kibana redis", + "size": "1124x1079", + "type": "image/png" + }, + { + "src": "/img/metricbeat_redis_key_dashboard.png", + "path": "/package/redis/1.18.0/img/metricbeat_redis_key_dashboard.png", + "title": "metricbeat redis key dashboard", + "size": "1855x949", + "type": "image/png" + }, + { + "src": "/img/metricbeat_redis_overview_dashboard.png", + "path": "/package/redis/1.18.0/img/metricbeat_redis_overview_dashboard.png", + "title": "metricbeat redis overview dashboard", + "size": "1855x949", + "type": "image/png" + } + ], + "assets": [ + "/package/redis/1.18.0/LICENSE.txt", + "/package/redis/1.18.0/changelog.yml", + "/package/redis/1.18.0/manifest.yml", + "/package/redis/1.18.0/docs/README.md", + "/package/redis/1.18.0/img/kibana-redis.png", + "/package/redis/1.18.0/img/logo_redis.svg", + "/package/redis/1.18.0/img/metricbeat_redis_key_dashboard.png", + "/package/redis/1.18.0/img/metricbeat_redis_overview_dashboard.png", + "/package/redis/1.18.0/data_stream/info/manifest.yml", + "/package/redis/1.18.0/data_stream/info/sample_event.json", + "/package/redis/1.18.0/data_stream/key/manifest.yml", + "/package/redis/1.18.0/data_stream/key/sample_event.json", + "/package/redis/1.18.0/data_stream/keyspace/manifest.yml", + "/package/redis/1.18.0/data_stream/keyspace/sample_event.json", + "/package/redis/1.18.0/data_stream/log/manifest.yml", + "/package/redis/1.18.0/data_stream/slowlog/manifest.yml", + "/package/redis/1.18.0/kibana/dashboard/redis-28969190-0511-11e9-9c60-d582a238e2c5.json", + "/package/redis/1.18.0/kibana/dashboard/redis-7fea2930-478e-11e7-b1f0-cb29bac6bf8b.json", + "/package/redis/1.18.0/kibana/dashboard/redis-AV4YjZ5pux-M-tCAunxK.json", + "/package/redis/1.18.0/data_stream/info/fields/agent.yml", + "/package/redis/1.18.0/data_stream/info/fields/base-fields.yml", + "/package/redis/1.18.0/data_stream/info/fields/ecs.yml", + "/package/redis/1.18.0/data_stream/info/fields/fields.yml", + "/package/redis/1.18.0/data_stream/key/fields/agent.yml", + "/package/redis/1.18.0/data_stream/key/fields/base-fields.yml", + "/package/redis/1.18.0/data_stream/key/fields/ecs.yml", + "/package/redis/1.18.0/data_stream/key/fields/fields.yml", + "/package/redis/1.18.0/data_stream/keyspace/fields/agent.yml", + "/package/redis/1.18.0/data_stream/keyspace/fields/base-fields.yml", + "/package/redis/1.18.0/data_stream/keyspace/fields/ecs.yml", + "/package/redis/1.18.0/data_stream/keyspace/fields/fields.yml", + "/package/redis/1.18.0/data_stream/log/fields/agent.yml", + "/package/redis/1.18.0/data_stream/log/fields/base-fields.yml", + "/package/redis/1.18.0/data_stream/log/fields/fields.yml", + "/package/redis/1.18.0/data_stream/slowlog/fields/agent.yml", + "/package/redis/1.18.0/data_stream/slowlog/fields/base-fields.yml", + "/package/redis/1.18.0/data_stream/slowlog/fields/fields.yml", + "/package/redis/1.18.0/data_stream/info/agent/stream/stream.yml.hbs", + "/package/redis/1.18.0/data_stream/key/agent/stream/stream.yml.hbs", + "/package/redis/1.18.0/data_stream/keyspace/agent/stream/stream.yml.hbs", + "/package/redis/1.18.0/data_stream/log/agent/stream/stream.yml.hbs", + "/package/redis/1.18.0/data_stream/log/elasticsearch/ingest_pipeline/default.yml", + "/package/redis/1.18.0/data_stream/slowlog/agent/stream/stream.yml.hbs", + "/package/redis/1.18.0/data_stream/slowlog/elasticsearch/ingest_pipeline/default.json" + ], + "policy_templates": [ + { + "name": "redis", + "title": "Redis logs and metrics", + "description": "Collect logs and metrics from Redis instances", + "inputs": [ + { + "type": "logfile", + "title": "Collect Redis application logs", + "description": "Collecting application logs from Redis instances" + }, + { + "type": "redis", + "title": "Collect Redis slow logs", + "description": "Collecting slow logs from Redis instances" + }, + { + "type": "redis/metrics", + "vars": [ + { + "name": "hosts", + "type": "text", + "title": "Hosts", + "multi": true, + "required": true, + "show_user": true, + "default": ["127.0.0.1:6379"] + }, + { + "name": "idle_timeout", + "type": "text", + "title": "Idle Timeout", + "multi": false, + "required": false, + "show_user": false, + "default": "20s" + }, + { + "name": "maxconn", + "type": "integer", + "title": "Maxconn", + "multi": false, + "required": false, + "show_user": false, + "default": 10 + }, + { + "name": "network", + "type": "text", + "title": "Network", + "multi": false, + "required": false, + "show_user": false, + "default": "tcp" + }, + { + "name": "username", + "type": "text", + "title": "Username", + "multi": false, + "required": false, + "show_user": false, + "default": "" + }, + { + "name": "password", + "type": "password", + "title": "Password", + "multi": false, + "required": false, + "show_user": false, + "default": "" + }, + { + "name": "ssl", + "type": "yaml", + "title": "SSL Configuration", + "description": "i.e. certificate_authorities, supported_protocols, verification_mode etc.", + "multi": false, + "required": false, + "show_user": false, + "default": "# ssl.certificate_authorities: |\n# -----BEGIN CERTIFICATE-----\n# MIID+jCCAuKgAwIBAgIGAJJMzlxLMA0GCSqGSIb3DQEBCwUAMHoxCzAJBgNVBAYT\n# AlVTMQwwCgYDVQQKEwNJQk0xFjAUBgNVBAsTDURlZmF1bHROb2RlMDExFjAUBgNV\n# BAsTDURlZmF1bHRDZWxsMDExGTAXBgNVBAsTEFJvb3QgQ2VydGlmaWNhdGUxEjAQ\n# BgNVBAMTCWxvY2FsaG9zdDAeFw0yMTEyMTQyMjA3MTZaFw0yMjEyMTQyMjA3MTZa\n# MF8xCzAJBgNVBAYTAlVTMQwwCgYDVQQKEwNJQk0xFjAUBgNVBAsTDURlZmF1bHRO\n# b2RlMDExFjAUBgNVBAsTDURlZmF1bHRDZWxsMDExEjAQBgNVBAMTCWxvY2FsaG9z\n# dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMv5HCsJZIpI5zCy+jXV\n# z6lmzNc9UcVSEEHn86h6zT6pxuY90TYeAhlZ9hZ+SCKn4OQ4GoDRZhLPTkYDt+wW\n# CV3NTIy9uCGUSJ6xjCKoxClJmgSQdg5m4HzwfY4ofoEZ5iZQ0Zmt62jGRWc0zuxj\n# hegnM+eO2reBJYu6Ypa9RPJdYJsmn1RNnC74IDY8Y95qn+WZj//UALCpYfX41hko\n# i7TWD9GKQO8SBmAxhjCDifOxVBokoxYrNdzESl0LXvnzEadeZTd9BfUtTaBHhx6t\n# njqqCPrbTY+3jAbZFd4RiERPnhLVKMytw5ot506BhPrUtpr2lusbN5svNXjuLeea\n# MMUCAwEAAaOBoDCBnTATBgNVHSMEDDAKgAhOatpLwvJFqjAdBgNVHSUEFjAUBggr\n# BgEFBQcDAQYIKwYBBQUHAwIwVAYDVR0RBE0wS4E+UHJvZmlsZVVVSUQ6QXBwU3J2\n# MDEtQkFTRS05MDkzMzJjMC1iNmFiLTQ2OTMtYWI5NC01Mjc1ZDI1MmFmNDiCCWxv\n# Y2FsaG9zdDARBgNVHQ4ECgQITzqhA5sO8O4wDQYJKoZIhvcNAQELBQADggEBAKR0\n# gY/BM69S6BDyWp5dxcpmZ9FS783FBbdUXjVtTkQno+oYURDrhCdsfTLYtqUlP4J4\n# CHoskP+MwJjRIoKhPVQMv14Q4VC2J9coYXnePhFjE+6MaZbTjq9WaekGrpKkMaQA\n# iQt5b67jo7y63CZKIo9yBvs7sxODQzDn3wZwyux2vPegXSaTHR/rop/s/mPk3YTS\n# hQprs/IVtPoWU4/TsDN3gIlrAYGbcs29CAt5q9MfzkMmKsuDkTZD0ry42VjxjAmk\n# xw23l/k8RoD1wRWaDVbgpjwSzt+kl+vJE/ip2w3h69eEZ9wbo6scRO5lCO2JM4Pr\n# 7RhLQyWn2u00L7/9Omw=\n# -----END CERTIFICATE-----\n" + } + ], + "title": "Collect Redis metrics", + "description": "Collecting info, key and keyspace metrics from Redis instances" + } + ], + "multiple": true + } + ], + "data_streams": [ + { + "type": "metrics", + "dataset": "redis.key", + "title": "Redis key metrics", + "release": "ga", + "streams": [ + { + "input": "redis/metrics", + "vars": [ + { + "name": "key.patterns", + "type": "yaml", + "title": "Key Patterns", + "multi": false, + "required": true, + "show_user": true, + "default": "- limit: 20\n pattern: '*'\n" + }, + { + "name": "period", + "type": "text", + "title": "Period", + "multi": false, + "required": true, + "show_user": true, + "default": "10s" + }, + { + "name": "processors", + "type": "yaml", + "title": "Processors", + "description": "Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the events are shipped. See [Processors](https://www.elastic.co/guide/en/fleet/current/elastic-agent-processor-configuration.html) for details. \n", + "multi": false, + "required": false, + "show_user": false + } + ], + "template_path": "stream.yml.hbs", + "title": "Redis key metrics", + "description": "Collect Redis key metrics", + "enabled": true + } + ], + "package": "redis", + "elasticsearch": {}, + "path": "key" + } + ] +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts new file mode 100644 index 0000000000000..5ff46f358bbe7 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/__fixtures__/redis_1_18_0_streams_template.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const REDIS_ASSETS_MAP = new Map([ + [ + 'redis-1.18.0/data_stream/slowlog/agent/stream/stream.yml.hbs', + Buffer.from(`hosts: +{{#each hosts as |host i|}} + - {{host}} +{{/each}} +password: {{password}} +{{#if processors}} +processors: +{{processors}} +{{/if}} +`), + ], + [ + 'redis-1.18.0/data_stream/log/agent/stream/stream.yml.hbs', + Buffer.from(`paths: +{{#each paths as |path i|}} + - {{path}} +{{/each}} +tags: +{{#if preserve_original_event}} + - preserve_original_event +{{/if}} +{{#each tags as |tag i|}} + - {{tag}} +{{/each}} +{{#contains "forwarded" tags}} +publisher_pipeline.disable_host: true +{{/contains}} +exclude_files: [".gz$"] +exclude_lines: ["^\\s+[\\-\`('.|_]"] # drop asciiart lines\n +{{#if processors}} +processors: +{{processors}} +{{/if}} +`), + ], + [ + 'redis-1.18.0/data_stream/key/agent/stream/stream.yml.hbs', + Buffer.from(`metricsets: ["key"] +hosts: +{{#each hosts}} + - {{this}} +{{/each}} +{{#if idle_timeout}} +idle_timeout: {{idle_timeout}} +{{/if}} +{{#if key.patterns}} +key.patterns: {{key.patterns}} +{{/if}} +{{#if maxconn}} +maxconn: {{maxconn}} +{{/if}} +{{#if network}} +network: {{network}} +{{/if}} +{{#if username}} +username: {{username}} +{{/if}} +{{#if password}} +password: {{password}} +{{/if}} +{{#if ssl}} +{{ssl}} +{{/if}} +period: {{period}} +{{#if processors}} +processors: +{{processors}} +{{/if}} +`), + ], +]); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap new file mode 100644 index 0000000000000..b3a428c0e5a55 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/__snapshots__/get_templates_inputs.test.ts.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Fleet - getTemplateInputs should work for input package 1`] = ` +"inputs: + # Custom log file: Collect your custom log files. + - id: logs-logfile + type: logfile + streams: + # Custom log file: Custom log file + - id: logfile-log.logs + data_stream: + dataset: <DATA_STREAM.DATASET> + # Dataset name: Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use \`-\` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html). + + paths: + - <PATHS> # Log file path: Path to log files to be collected + exclude_files: + - <EXCLUDE_FILES> # Exclude files: Patterns to be ignored + ignore_older: 72h + tags: + - <TAGS> # Tags: Tags to include in the published event +" +`; + +exports[`Fleet - getTemplateInputs should work for integration package 1`] = ` +"inputs: + # Collect Redis application logs: Collecting application logs from Redis instances + - id: redis-logfile + type: logfile + # Collect Redis slow logs: Collecting slow logs from Redis instances + - id: redis-redis + type: redis + # Collect Redis metrics: Collecting info, key and keyspace metrics from Redis instances + - id: redis-redis/metrics + type: redis/metrics + streams: + # Redis key metrics: Collect Redis key metrics + - id: redis/metrics-redis.key + data_stream: + dataset: redis.key + type: metrics + metricsets: + - key + hosts: + - 127.0.0.1:6379 + idle_timeout: 20s + key.patterns: + - limit: 20 + pattern: '*' + maxconn: 10 + network: tcp + username: <USERNAME> # Username + password: <PASSWORD> # Password + period: 10s +" +`; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts index 640fc3877eabf..8c63f4b093dd0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_template_inputs.ts @@ -8,8 +8,14 @@ import type { SavedObjectsClientContract } from '@kbn/core/server'; import { merge } from 'lodash'; import { dump } from 'js-yaml'; +import yamlDoc from 'yaml'; -import { packageToPackagePolicy } from '../../../../common/services/package_to_package_policy'; +import { getNormalizedInputs, isIntegrationPolicyTemplate } from '../../../../common/services'; + +import { + getStreamsForInputType, + packageToPackagePolicy, +} from '../../../../common/services/package_to_package_policy'; import { getInputsWithStreamIds, _compilePackagePolicyInputs } from '../../package_policy'; import { appContextService } from '../../app_context'; import type { @@ -17,6 +23,10 @@ import type { NewPackagePolicy, PackagePolicyInput, TemplateAgentPolicyInput, + RegistryVarsEntry, + RegistryStream, + PackagePolicyConfigRecordEntry, + RegistryInput, } from '../../../../common/types'; import { _sortYamlKeys } from '../../../../common/services/full_agent_policy_to_yaml'; @@ -27,6 +37,18 @@ import { getPackageAssetsMap } from './get'; type Format = 'yml' | 'json'; +type PackageWithInputAndStreamIndexed = Record< + string, + RegistryInput & { + streams: Record< + string, + RegistryStream & { + data_stream: { type: string; dataset: string }; + } + >; + } +>; + // Function based off storedPackagePolicyToAgentInputs, it only creates the `streams` section instead of the FullAgentPolicyInput export const templatePackagePolicyToFullInputStreams = ( packagePolicyInputs: PackagePolicyInput[] @@ -38,7 +60,7 @@ export const templatePackagePolicyToFullInputStreams = ( packagePolicyInputs.forEach((input) => { const fullInputStream = { // @ts-ignore-next-line the following id is actually one level above the one in fullInputStream, but the linter thinks it gets overwritten - id: input.policy_template ? `${input.type}-${input.policy_template}` : `${input.type}`, + id: input.policy_template ? `${input.policy_template}-${input.type}` : `${input.type}`, type: input.type, ...getFullInputStreams(input, true), }; @@ -81,22 +103,53 @@ export async function getTemplateInputs( prerelease?: boolean, ignoreUnverified?: boolean ) { - const packageInfoMap = new Map<string, PackageInfo>(); - let packageInfo: PackageInfo; - - if (packageInfoMap.has(pkgName)) { - packageInfo = packageInfoMap.get(pkgName)!; - } else { - packageInfo = await getPackageInfo({ - savedObjectsClient: soClient, - pkgName, - pkgVersion, - prerelease, - ignoreUnverified, - }); - } + const packageInfo = await getPackageInfo({ + savedObjectsClient: soClient, + pkgName, + pkgVersion, + prerelease, + ignoreUnverified, + }); + const emptyPackagePolicy = packageToPackagePolicy(packageInfo, ''); + const inputsWithStreamIds = getInputsWithStreamIds(emptyPackagePolicy, undefined, true); + + const indexedInputsAndStreams = buildIndexedPackage(packageInfo); + + if (format === 'yml') { + // Add a placeholder <VAR_NAME> to all variables without default value + for (const inputWithStreamIds of inputsWithStreamIds) { + const inputId = inputWithStreamIds.policy_template + ? `${inputWithStreamIds.policy_template}-${inputWithStreamIds.type}` + : inputWithStreamIds.type; + + const packageInput = indexedInputsAndStreams[inputId]; + if (!packageInput) { + continue; + } + + for (const [inputVarKey, inputVarValue] of Object.entries(inputWithStreamIds.vars ?? {})) { + const varDef = packageInput.vars?.find((_varDef) => _varDef.name === inputVarKey); + if (varDef) { + addPlaceholderIfNeeded(varDef, inputVarValue); + } + } + for (const stream of inputWithStreamIds.streams) { + const packageStream = packageInput.streams[stream.id]; + if (!packageStream) { + continue; + } + for (const [streamVarKey, streamVarValue] of Object.entries(stream.vars ?? {})) { + const varDef = packageStream.vars?.find((_varDef) => _varDef.name === streamVarKey); + if (varDef) { + addPlaceholderIfNeeded(varDef, streamVarValue); + } + } + } + } + } + const assetsMap = await getPackageAssetsMap({ logger: appContextService.getLogger(), packageInfo, @@ -128,7 +181,146 @@ export async function getTemplateInputs( sortKeys: _sortYamlKeys, } ); - return yaml; + return addCommentsToYaml(yaml, buildIndexedPackage(packageInfo)); } + return { inputs: [] }; } + +function getPlaceholder(varDef: RegistryVarsEntry) { + return `<${varDef.name.toUpperCase()}>`; +} + +function addPlaceholderIfNeeded( + varDef: RegistryVarsEntry, + varValue: PackagePolicyConfigRecordEntry +) { + const placeHolder = `<${varDef.name.toUpperCase()}>`; + if (varDef && !varValue.value && varDef.type !== 'yaml') { + varValue.value = placeHolder; + } else if (varDef && varValue.value && varValue.value.length === 0 && varDef.type === 'text') { + varValue.value = [placeHolder]; + } +} + +function buildIndexedPackage(packageInfo: PackageInfo): PackageWithInputAndStreamIndexed { + return ( + packageInfo.policy_templates?.reduce<PackageWithInputAndStreamIndexed>( + (inputsAcc, policyTemplate) => { + const inputs = getNormalizedInputs(policyTemplate); + + inputs.forEach((packageInput) => { + const inputId = `${policyTemplate.name}-${packageInput.type}`; + + const streams = getStreamsForInputType( + packageInput.type, + packageInfo, + isIntegrationPolicyTemplate(policyTemplate) && policyTemplate.data_streams + ? policyTemplate.data_streams + : [] + ).reduce< + Record< + string, + RegistryStream & { + data_stream: { type: string; dataset: string }; + } + > + >((acc, stream) => { + const streamId = `${packageInput.type}-${stream.data_stream.dataset}`; + acc[streamId] = { + ...stream, + }; + return acc; + }, {}); + + inputsAcc[inputId] = { + ...packageInput, + streams, + }; + }); + return inputsAcc; + }, + {} + ) ?? {} + ); +} + +function addCommentsToYaml( + yaml: string, + packageIndexInputAndStreams: PackageWithInputAndStreamIndexed +) { + const doc = yamlDoc.parseDocument(yaml); + // Add input and streams comments + const yamlInputs = doc.get('inputs'); + if (yamlDoc.isCollection(yamlInputs)) { + yamlInputs.items.forEach((inputItem) => { + if (!yamlDoc.isMap(inputItem)) { + return; + } + const inputIdNode = inputItem.get('id', true); + if (!yamlDoc.isScalar(inputIdNode)) { + return; + } + const inputId = inputIdNode.value as string; + const pkgInput = packageIndexInputAndStreams[inputId]; + if (pkgInput) { + inputItem.commentBefore = ` ${pkgInput.title}${ + pkgInput.description ? `: ${pkgInput.description}` : '' + }`; + + yamlDoc.visit(inputItem, { + Scalar(key, node) { + if (node.value) { + const val = node.value.toString(); + for (const varDef of pkgInput.vars ?? []) { + const placeholder = getPlaceholder(varDef); + if (val.includes(placeholder)) { + node.comment = ` ${varDef.title}${ + varDef.description ? `: ${varDef.description}` : '' + }`; + } + } + } + }, + }); + + const yamlStreams = inputItem.get('streams'); + if (!yamlDoc.isCollection(yamlStreams)) { + return; + } + yamlStreams.items.forEach((streamItem) => { + if (!yamlDoc.isMap(streamItem)) { + return; + } + const streamIdNode = streamItem.get('id', true); + if (yamlDoc.isScalar(streamIdNode)) { + const streamId = streamIdNode.value as string; + const pkgStream = pkgInput.streams[streamId]; + if (pkgStream) { + streamItem.commentBefore = ` ${pkgStream.title}${ + pkgStream.description ? `: ${pkgStream.description}` : '' + }`; + yamlDoc.visit(streamItem, { + Scalar(key, node) { + if (node.value) { + const val = node.value.toString(); + for (const varDef of pkgStream.vars ?? []) { + const placeholder = getPlaceholder(varDef); + if (val.includes(placeholder)) { + node.comment = ` ${varDef.title}${ + varDef.description ? `: ${varDef.description}` : '' + }`; + } + } + } + }, + }); + } + } + }); + } + }); + } + + return doc.toString(); +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts index ce80532b3b623..087002f212852 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_templates_inputs.test.ts @@ -5,9 +5,19 @@ * 2.0. */ +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; + +import { createAppContextStartContractMock } from '../../../mocks'; import type { PackagePolicyInput } from '../../../../common/types'; +import { appContextService } from '../..'; + +import { getTemplateInputs, templatePackagePolicyToFullInputStreams } from './get_template_inputs'; +import REDIS_1_18_0_PACKAGE_INFO from './__fixtures__/redis_1_18_0_package_info.json'; +import { getPackageAssetsMap, getPackageInfo } from './get'; +import { REDIS_ASSETS_MAP } from './__fixtures__/redis_1_18_0_streams_template'; +import { LOGS_2_3_0_ASSETS_MAP, LOGS_2_3_0_PACKAGE_INFO } from './__fixtures__/logs_2_3_0'; -import { templatePackagePolicyToFullInputStreams } from './get_template_inputs'; +jest.mock('./get'); const packageInfoCache = new Map(); packageInfoCache.set('mock_package-0.0.0', { @@ -29,6 +39,9 @@ packageInfoCache.set('limited_package-0.0.0', { ], }); +packageInfoCache.set('redis-1.18.0', REDIS_1_18_0_PACKAGE_INFO); +packageInfoCache.set('log-2.3.0', LOGS_2_3_0_PACKAGE_INFO); + describe('Fleet - templatePackagePolicyToFullInputStreams', () => { const mockInput: PackagePolicyInput = { type: 'test-logs', @@ -189,7 +202,7 @@ describe('Fleet - templatePackagePolicyToFullInputStreams', () => { it('returns agent inputs without streams', async () => { expect(await templatePackagePolicyToFullInputStreams([mockInput2])).toEqual([ { - id: 'test-metrics-some-template', + id: 'some-template-test-metrics', type: 'test-metrics', streams: [ { @@ -305,3 +318,43 @@ describe('Fleet - templatePackagePolicyToFullInputStreams', () => { ]); }); }); + +describe('Fleet - getTemplateInputs', () => { + beforeEach(() => { + appContextService.start(createAppContextStartContractMock()); + jest.mocked(getPackageAssetsMap).mockImplementation(async ({ packageInfo }) => { + if (packageInfo.name === 'redis' && packageInfo.version === '1.18.0') { + return REDIS_ASSETS_MAP; + } + + if (packageInfo.name === 'log') { + return LOGS_2_3_0_ASSETS_MAP; + } + + return new Map(); + }); + jest.mocked(getPackageInfo).mockImplementation(async ({ pkgName, pkgVersion }) => { + const pkgInfo = packageInfoCache.get(`${pkgName}-${pkgVersion}`); + if (!pkgInfo) { + throw new Error('package not mocked'); + } + + return pkgInfo; + }); + }); + it('should work for integration package', async () => { + const soMock = savedObjectsClientMock.create(); + soMock.get.mockResolvedValue({ attributes: {} } as any); + const template = await getTemplateInputs(soMock, 'redis', '1.18.0', 'yml'); + + expect(template).toMatchSnapshot(); + }); + + it('should work for input package', async () => { + const soMock = savedObjectsClientMock.create(); + soMock.get.mockResolvedValue({ attributes: {} } as any); + const template = await getTemplateInputs(soMock, 'log', '2.3.0', 'yml'); + + expect(template).toMatchSnapshot(); + }); +}); diff --git a/x-pack/test/fleet_api_integration/apis/epm/get_templates_inputs.ts b/x-pack/test/fleet_api_integration/apis/epm/get_templates_inputs.ts index a1eac19eed8b7..cca480c45f56d 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/get_templates_inputs.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/get_templates_inputs.ts @@ -51,9 +51,11 @@ export default function (providerContext: FtrProviderContext) { await uninstallPackage(testPkgName, testPkgVersion); }); const expectedYml = `inputs: - - id: logfile-apache + # Collect logs from Apache instances: Collecting Apache access and error logs + - id: apache-logfile type: logfile streams: + # Apache access logs: Collect Apache access logs - id: logfile-apache.access data_stream: dataset: apache.access @@ -69,6 +71,7 @@ export default function (providerContext: FtrProviderContext) { target: '' fields: ecs.version: 1.5.0 + # Apache error logs: Collect Apache error logs - id: logfile-apache.error data_stream: dataset: apache.error @@ -84,9 +87,11 @@ export default function (providerContext: FtrProviderContext) { target: '' fields: ecs.version: 1.5.0 - - id: apache/metrics-apache + # Collect metrics from Apache instances: Collecting Apache status metrics + - id: apache-apache/metrics type: apache/metrics streams: + # Apache status metrics: Collect Apache status metrics - id: apache/metrics-apache.status data_stream: dataset: apache.status @@ -100,7 +105,7 @@ export default function (providerContext: FtrProviderContext) { `; const expectedJson = [ { - id: 'logfile-apache', + id: 'apache-logfile', type: 'logfile', streams: [ { @@ -151,7 +156,7 @@ export default function (providerContext: FtrProviderContext) { ], }, { - id: 'apache/metrics-apache', + id: 'apache-apache/metrics', type: 'apache/metrics', streams: [ { diff --git a/yarn.lock b/yarn.lock index 11778ed7abcc9..ec4c8f0e0837f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32974,6 +32974,11 @@ yaml@^2.0.0, yaml@^2.2.1, yaml@^2.2.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2" integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA== +yaml@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.1.tgz#c9772aacf62cb7494a95b0c4f1fb065b563db130" + integrity sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q== + yargs-parser@20.2.4, yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.4" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" From dbde89f1ed2e31d1de0f8ba9e45b513d7b45617e Mon Sep 17 00:00:00 2001 From: Jared Burgett <147995946+jaredburgettelastic@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:01:35 -0500 Subject: [PATCH 12/31] Fixed eslint 'Switch' to 'Routes' in Entity Analytics (#196433) Fixed a typing issue, due to a merge conflict related to ESLint changes --- .../security_solution/public/entity_analytics/routes.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx index 1cc1a24b020cb..7dc9970da5d9b 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx @@ -81,14 +81,14 @@ const EntityAnalyticsEntityStoreTelemetry = () => ( const EntityAnalyticsEntityStoreContainer: React.FC = React.memo(() => { return ( - <Switch> + <Routes> <Route path={ENTITY_ANALYTICS_ENTITY_STORE_MANAGEMENT_PATH} exact component={EntityAnalyticsEntityStoreTelemetry} /> <Route component={NotFoundPage} /> - </Switch> + </Routes> ); }); From b4c3ab55a0680db2ec1a9d2f01051266f599e172 Mon Sep 17 00:00:00 2001 From: Maryam Saeidi <maryam.saeidi@elastic.co> Date: Tue, 15 Oct 2024 22:05:11 +0200 Subject: [PATCH 13/31] [Related alerts] Add related alerts for all the observability rules (#195592) Closes #193942 Closes #193952 ## Summary This PR adds related alert logic for all the observability rules, as mentioned in #193942. Also, it adds a beta badge for this new tab. ![image](https://github.com/user-attachments/assets/43f7cf6a-670f-4a85-a11c-769d2b2f9625) --- .../alerting/get_related_alerts_query.test.ts | 80 +++++++++++++++- .../alerting/get_related_alerts_query.ts | 86 +++++++++++++++-- .../common/utils/alerting/types.ts | 14 +++ .../get_alerts_page_table_configuration.tsx | 7 +- .../register_alerts_table_configuration.tsx | 12 +++ .../alert_details_app_section.test.tsx | 11 --- .../alert_details_app_section.tsx | 13 +-- .../public/components/experimental_badge.tsx | 7 +- .../observability/public/constants.ts | 1 + .../pages/alert_details/alert_details.tsx | 32 ++++--- ...on_product_no_results_magnifying_glass.svg | 1 + .../components/related_alerts.tsx | 94 ++++++++++++++++--- 12 files changed, 294 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/observability_solution/observability/common/utils/alerting/types.ts create mode 100644 x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/assets/illustration_product_no_results_magnifying_glass.svg diff --git a/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.test.ts b/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.test.ts index b7b8d138f471a..d5e6cd09dab00 100644 --- a/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.test.ts +++ b/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.test.ts @@ -5,25 +5,56 @@ * 2.0. */ -import { getRelatedAlertKuery } from './get_related_alerts_query'; +import { + getRelatedAlertKuery, + SERVICE_NAME, + MONITOR_ID, + OBSERVER_NAME, + HOST, + KUBERNETES_POD, + DOCKER_CONTAINER, + EC2_INSTANCE, + S3_BUCKETS, + RDS_DATABASES, + SQS_QUEUES, +} from './get_related_alerts_query'; import { fromKueryExpression } from '@kbn/es-query'; describe('getRelatedAlertKuery', () => { - const tags = ['tag1:v', 'tag2']; + const tags = ['tag1:v', 'tag2', 'apm']; const groups = [ { field: 'group1Field', value: 'group1Value' }, { field: 'group2Field', value: 'group2:Value' }, ]; + const ruleId = 'ruleUuid'; + const sharedFields = [ + { name: SERVICE_NAME, value: `my-${SERVICE_NAME}` }, + { name: MONITOR_ID, value: `my-${MONITOR_ID}` }, + { name: OBSERVER_NAME, value: `my-${OBSERVER_NAME}` }, + { name: HOST, value: `my-${HOST}` }, + { name: KUBERNETES_POD, value: `my-${KUBERNETES_POD}` }, + { name: DOCKER_CONTAINER, value: `my-${DOCKER_CONTAINER}` }, + { name: EC2_INSTANCE, value: `my-${EC2_INSTANCE}` }, + { name: S3_BUCKETS, value: `my-${S3_BUCKETS}` }, + { name: RDS_DATABASES, value: `my-${RDS_DATABASES}` }, + { name: SQS_QUEUES, value: `my-${SQS_QUEUES}` }, + ]; const tagsKuery = '(tags: "tag1:v" or tags: "tag2")'; const groupsKuery = '(group1Field: "group1Value" or kibana.alert.group.value: "group1Value") or (group2Field: "group2:Value" or kibana.alert.group.value: "group2:Value")'; + const ruleKuery = '(kibana.alert.rule.uuid: "ruleUuid")'; + const sharedFieldsKuery = + `(service.name: "my-service.name") or (monitor.id: "my-monitor.id") or (observer.name: "my-observer.name")` + + ` or (host.name: "my-host.name") or (kubernetes.pod.uid: "my-kubernetes.pod.uid") or (container.id: "my-container.id")` + + ` or (cloud.instance.id: "my-cloud.instance.id") or (aws.s3.bucket.name: "my-aws.s3.bucket.name")` + + ` or (aws.rds.db_instance.arn: "my-aws.rds.db_instance.arn") or (aws.sqs.queue.name: "my-aws.sqs.queue.name")`; it('should generate correct query with no tags or groups', () => { expect(getRelatedAlertKuery()).toBeUndefined(); }); it('should generate correct query for tags', () => { - const kuery = getRelatedAlertKuery(tags); + const kuery = getRelatedAlertKuery({ tags }); expect(kuery).toEqual(tagsKuery); // Should be able to parse keury without throwing error @@ -31,7 +62,7 @@ describe('getRelatedAlertKuery', () => { }); it('should generate correct query for groups', () => { - const kuery = getRelatedAlertKuery(undefined, groups); + const kuery = getRelatedAlertKuery({ groups }); expect(kuery).toEqual(groupsKuery); // Should be able to parse keury without throwing error @@ -39,10 +70,49 @@ describe('getRelatedAlertKuery', () => { }); it('should generate correct query for tags and groups', () => { - const kuery = getRelatedAlertKuery(tags, groups); + const kuery = getRelatedAlertKuery({ tags, groups }); expect(kuery).toEqual(`${tagsKuery} or ${groupsKuery}`); // Should be able to parse keury without throwing error fromKueryExpression(kuery!); }); + + it('should generate correct query for tags, groups and ruleId', () => { + const kuery = getRelatedAlertKuery({ tags, groups, ruleId }); + expect(kuery).toEqual(`${tagsKuery} or ${groupsKuery} or ${ruleKuery}`); + + // Should be able to parse keury without throwing error + fromKueryExpression(kuery!); + }); + + it('should generate correct query for sharedFields', () => { + const kuery = getRelatedAlertKuery({ sharedFields }); + expect(kuery).toEqual(sharedFieldsKuery); + + // Should be able to parse keury without throwing error + fromKueryExpression(kuery!); + }); + + it('should generate correct query when all the fields are provided', () => { + const kuery = getRelatedAlertKuery({ tags, groups, ruleId, sharedFields }); + expect(kuery).toEqual(`${tagsKuery} or ${groupsKuery} or ${sharedFieldsKuery} or ${ruleKuery}`); + + // Should be able to parse keury without throwing error + fromKueryExpression(kuery!); + }); + + it('should not include service.name twice', () => { + const serviceNameGroups = [{ field: 'service.name', value: 'myServiceName' }]; + const serviceNameSharedFields = [{ name: SERVICE_NAME, value: `my-${SERVICE_NAME}` }]; + const kuery = getRelatedAlertKuery({ + groups: serviceNameGroups, + sharedFields: serviceNameSharedFields, + }); + expect(kuery).toEqual( + `(service.name: "myServiceName" or kibana.alert.group.value: "myServiceName")` + ); + + // Should be able to parse keury without throwing error + fromKueryExpression(kuery!); + }); }); diff --git a/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.ts b/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.ts index cb2ad27bc8981..bd5be2b7822b0 100644 --- a/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.ts +++ b/x-pack/plugins/observability_solution/observability/common/utils/alerting/get_related_alerts_query.ts @@ -5,28 +5,102 @@ * 2.0. */ +import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import type { Group } from '../../typings'; export interface Query { query: string; language: string; } +export interface Field { + name: string; + value: string; +} +interface Props { + tags?: string[]; + groups?: Group[]; + ruleId?: string; + sharedFields?: Field[]; +} + +// APM rules +export const SERVICE_NAME = 'service.name'; +// Synthetics rules +export const MONITOR_ID = 'monitor.id'; +// - location +export const OBSERVER_NAME = 'observer.name'; +// Inventory rule +export const HOST = 'host.name'; +export const KUBERNETES_POD = 'kubernetes.pod.uid'; +export const DOCKER_CONTAINER = 'container.id'; +export const EC2_INSTANCE = 'cloud.instance.id'; +export const S3_BUCKETS = 'aws.s3.bucket.name'; +export const RDS_DATABASES = 'aws.rds.db_instance.arn'; +export const SQS_QUEUES = 'aws.sqs.queue.name'; + +const ALL_SHARED_FIELDS = [ + SERVICE_NAME, + MONITOR_ID, + OBSERVER_NAME, + HOST, + KUBERNETES_POD, + DOCKER_CONTAINER, + EC2_INSTANCE, + S3_BUCKETS, + RDS_DATABASES, + SQS_QUEUES, +]; + +interface AlertFields { + [key: string]: any; +} + +export const getSharedFields = (alertFields: AlertFields = {}) => { + const matchedFields: Field[] = []; + ALL_SHARED_FIELDS.forEach((source) => { + Object.keys(alertFields).forEach((field) => { + if (source === field) { + const fieldValue = alertFields[field]; + matchedFields.push({ + name: source, + value: Array.isArray(fieldValue) ? fieldValue[0] : fieldValue, + }); + } + }); + }); + + return matchedFields; +}; + +const EXCLUDE_TAGS = ['apm']; -export const getRelatedAlertKuery = (tags?: string[], groups?: Group[]): string | undefined => { - const tagKueries: string[] = - tags?.map((tag) => { - return `tags: "${tag}"`; - }) ?? []; +export const getRelatedAlertKuery = ({ tags, groups, ruleId, sharedFields }: Props = {}): + | string + | undefined => { + const tagKueries = + tags + ?.filter((tag) => !EXCLUDE_TAGS.includes(tag)) + .map((tag) => { + return `tags: "${tag}"`; + }) ?? []; const groupKueries = (groups && groups.map(({ field, value }) => { return `(${field}: "${value}" or kibana.alert.group.value: "${value}")`; })) ?? []; + const ruleKueries = (ruleId && [`(${ALERT_RULE_UUID}: "${ruleId}")`]) ?? []; + const groupFields = groups?.map((group) => group.field) ?? []; + const sharedFieldsKueries = + sharedFields + ?.filter((field) => !groupFields.includes(field.name)) + .map((field) => { + return `(${field.name}: "${field.value}")`; + }) ?? []; const tagKueriesStr = tagKueries.length > 0 ? [`(${tagKueries.join(' or ')})`] : []; const groupKueriesStr = groupKueries.length > 0 ? [`${groupKueries.join(' or ')}`] : []; - const kueries = [...tagKueriesStr, ...groupKueriesStr]; + const kueries = [...tagKueriesStr, ...groupKueriesStr, ...sharedFieldsKueries, ...ruleKueries]; return kueries.length ? kueries.join(' or ') : undefined; }; diff --git a/x-pack/plugins/observability_solution/observability/common/utils/alerting/types.ts b/x-pack/plugins/observability_solution/observability/common/utils/alerting/types.ts new file mode 100644 index 0000000000000..ac68b45514bd2 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/common/utils/alerting/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ALERT_GROUP, TAGS } from '@kbn/rule-data-utils'; +import { Group } from '../../typings'; + +export interface ObservabilityFields { + [ALERT_GROUP]?: Group[]; + [TAGS]?: string[]; +} diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx index 30c912b510743..8282d90752d7c 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/alerts/get_alerts_page_table_configuration.tsx @@ -34,7 +34,8 @@ export const getAlertsPageTableConfiguration = ( config: ConfigSchema, dataViews: DataViewsServicePublic, http: HttpSetup, - notifications: NotificationsStart + notifications: NotificationsStart, + id?: string ): AlertsTableConfigurationRegistry => { const renderCustomActionsRow = (props: RenderCustomActionsRowArgs) => { return ( @@ -46,7 +47,7 @@ export const getAlertsPageTableConfiguration = ( ); }; return { - id: ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, + id: id ?? ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, cases: { featureId: casesFeatureId, owner: [observabilityFeatureId] }, columns: getColumns({ showRuleName: true }), getRenderCellValue, @@ -66,7 +67,7 @@ export const getAlertsPageTableConfiguration = ( }, ruleTypeIds: observabilityRuleTypeRegistry.list(), usePersistentControls: getPersistentControlsHook({ - groupingId: ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, + groupingId: id ?? ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, featureIds: observabilityAlertFeatureIds, services: { dataViews, diff --git a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx index de687c4dd7944..bc18c54d22ee3 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/alerts_table/register_alerts_table_configuration.tsx @@ -9,6 +9,7 @@ import { AlertTableConfigRegistry } from '@kbn/triggers-actions-ui-plugin/public import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types'; import { HttpSetup } from '@kbn/core-http-browser'; import { NotificationsStart } from '@kbn/core-notifications-browser'; +import { RELATED_ALERTS_TABLE_CONFIG_ID } from '../../constants'; import type { ConfigSchema } from '../../plugin'; import { ObservabilityRuleTypeRegistry } from '../..'; import { getAlertsPageTableConfiguration } from './alerts/get_alerts_page_table_configuration'; @@ -41,6 +42,17 @@ export const registerAlertsTableConfiguration = ( ); alertTableConfigRegistry.register(alertsPageAlertsTableConfig); + // Alert details page + const alertDetailsPageAlertsTableConfig = getAlertsPageTableConfiguration( + observabilityRuleTypeRegistry, + config, + dataViews, + http, + notifications, + RELATED_ALERTS_TABLE_CONFIG_ID + ); + alertTableConfigRegistry.register(alertDetailsPageAlertsTableConfig); + // Rule details page const ruleDetailsAlertsTableConfig = getRuleDetailsTableConfiguration( observabilityRuleTypeRegistry, diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.test.tsx b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.test.tsx index de74fe2ec14b9..f45a353be9a61 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.test.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.test.tsx @@ -76,7 +76,6 @@ jest.mock('../../../../utils/kibana_react', () => ({ describe('AlertDetailsAppSection', () => { const queryClient = new QueryClient(); - const mockedSetRelatedAlertsKuery = jest.fn(); const renderComponent = ( alert: Partial<CustomThresholdAlert> = {}, @@ -88,7 +87,6 @@ describe('AlertDetailsAppSection', () => { <AlertDetailsAppSection alert={buildCustomThresholdAlert(alert, alertFields)} rule={buildCustomThresholdRule()} - setRelatedAlertsKuery={mockedSetRelatedAlertsKuery} /> </QueryClientProvider> </IntlProvider> @@ -118,15 +116,6 @@ describe('AlertDetailsAppSection', () => { expect(mockedRuleConditionChart.mock.calls[0]).toMatchSnapshot(); }); - it('should set relatedAlertsKuery', async () => { - renderComponent(); - - expect(mockedSetRelatedAlertsKuery).toBeCalledTimes(1); - expect(mockedSetRelatedAlertsKuery).toHaveBeenLastCalledWith( - '(tags: "tag 1" or tags: "tag 2") or (host.name: "host-1" or kibana.alert.group.value: "host-1")' - ); - }); - it('should render title on condition charts', async () => { const result = renderComponent(); diff --git a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.tsx b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.tsx index 7885301650ecf..b474f246988b6 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/custom_threshold/components/alert_details_app_section/alert_details_app_section.tsx @@ -33,7 +33,6 @@ import moment from 'moment'; import { LOGS_EXPLORER_LOCATOR_ID, LogsExplorerLocatorParams } from '@kbn/deeplinks-observability'; import { TimeRange } from '@kbn/es-query'; import { getGroupFilters } from '../../../../../common/custom_threshold_rule/helpers/get_group'; -import { getRelatedAlertKuery } from '../../../../../common/utils/alerting/get_related_alerts_query'; import { useLicense } from '../../../../hooks/use_license'; import { useKibana } from '../../../../utils/kibana_react'; import { metricValueFormatter } from '../../../../../common/custom_threshold_rule/metric_value_formatter'; @@ -49,15 +48,10 @@ import { generateChartTitleAndTooltip } from './helpers/generate_chart_title_and interface AppSectionProps { alert: CustomThresholdAlert; rule: CustomThresholdRule; - setRelatedAlertsKuery: React.Dispatch<React.SetStateAction<string | undefined>>; } // eslint-disable-next-line import/no-default-export -export default function AlertDetailsAppSection({ - alert, - rule, - setRelatedAlertsKuery, -}: AppSectionProps) { +export default function AlertDetailsAppSection({ alert, rule }: AppSectionProps) { const services = useKibana().services; const { charts, @@ -79,7 +73,6 @@ export default function AlertDetailsAppSection({ const alertStart = alert.fields[ALERT_START]; const alertEnd = alert.fields[ALERT_END]; const groups = alert.fields[ALERT_GROUP]; - const tags = alert.fields.tags; const chartTitleAndTooltip: Array<{ title: string; tooltip: string }> = []; @@ -112,10 +105,6 @@ export default function AlertDetailsAppSection({ const annotations: EventAnnotationConfig[] = []; annotations.push(alertStartAnnotation, alertRangeAnnotation); - useEffect(() => { - setRelatedAlertsKuery(getRelatedAlertKuery(tags, groups)); - }, [groups, setRelatedAlertsKuery, tags]); - useEffect(() => { setTimeRange(getPaddedAlertTimeRange(alertStart!, alertEnd)); }, [alertStart, alertEnd]); diff --git a/x-pack/plugins/observability_solution/observability/public/components/experimental_badge.tsx b/x-pack/plugins/observability_solution/observability/public/components/experimental_badge.tsx index 19d48b449f691..399e2b783eaaa 100644 --- a/x-pack/plugins/observability_solution/observability/public/components/experimental_badge.tsx +++ b/x-pack/plugins/observability_solution/observability/public/components/experimental_badge.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiBetaBadge } from '@elastic/eui'; +import { EuiBetaBadge, EuiBetaBadgeProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -23,7 +23,9 @@ export function ExperimentalBadge() { ); } -export function BetaBadge() { +export function BetaBadge( + badgeProps: Partial<Pick<EuiBetaBadgeProps, 'size' | 'iconType' | 'style'>> +) { return ( <EuiBetaBadge label={i18n.translate('xpack.observability.betaBadgeLabel', { @@ -32,6 +34,7 @@ export function BetaBadge() { tooltipContent={i18n.translate('xpack.observability.betaBadgeDescription', { defaultMessage: 'This functionality is in beta and is subject to change.', })} + {...badgeProps} /> ); } diff --git a/x-pack/plugins/observability_solution/observability/public/constants.ts b/x-pack/plugins/observability_solution/observability/public/constants.ts index 7af5d9380f6cc..b7a1ecea9c3f8 100644 --- a/x-pack/plugins/observability_solution/observability/public/constants.ts +++ b/x-pack/plugins/observability_solution/observability/public/constants.ts @@ -9,6 +9,7 @@ export const DEFAULT_INTERVAL = '60s'; export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; export const ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID = `alerts-page-alerts-table`; +export const RELATED_ALERTS_TABLE_CONFIG_ID = `related-alerts-table`; export const RULE_DETAILS_ALERTS_TABLE_CONFIG_ID = `rule-details-alerts-table`; export const SEARCH_BAR_URL_STORAGE_KEY = 'searchBarParams'; diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/alert_details.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/alert_details.tsx index 6997e60e0a5af..8f5acee54f57e 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/alert_details.tsx @@ -6,8 +6,9 @@ */ import React, { useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { EuiEmptyPrompt, EuiPanel, @@ -32,11 +33,12 @@ import dedent from 'dedent'; import { AlertFieldsTable } from '@kbn/alerts-ui-shared'; import { css } from '@emotion/react'; import { omit } from 'lodash'; +import { BetaBadge } from '../../components/experimental_badge'; +import { RelatedAlerts } from './components/related_alerts'; import { AlertDetailsSource } from './types'; import { SourceBar } from './components'; import { StatusBar } from './components/status_bar'; import { observabilityFeatureId } from '../../../common'; -import { RelatedAlerts } from './components/related_alerts'; import { useKibana } from '../../utils/kibana_react'; import { useFetchRule } from '../../hooks/use_fetch_rule'; import { usePluginContext } from '../../hooks/use_plugin_context'; @@ -109,7 +111,6 @@ export function AlertDetails() { const { euiTheme } = useEuiTheme(); const [sources, setSources] = useState<AlertDetailsSource[]>(); - const [relatedAlertsKuery, setRelatedAlertsKuery] = useState<string>(); const [activeTabId, setActiveTabId] = useState<TabId>(() => { const searchParams = new URLSearchParams(search); const urlTabId = searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY); @@ -225,7 +226,6 @@ export function AlertDetails() { rule={rule} timeZone={timeZone} setSources={setSources} - setRelatedAlertsKuery={setRelatedAlertsKuery} /> <AlertHistoryChart alert={alertDetail.formatted} @@ -271,18 +271,22 @@ export function AlertDetails() { 'data-test-subj': 'metadataTab', content: metadataTab, }, - ]; - - if (relatedAlertsKuery && alertDetail?.formatted) { - tabs.push({ + { id: RELATED_ALERTS_TAB_ID, - name: i18n.translate('xpack.observability.alertDetails.tab.relatedAlertsLabel', { - defaultMessage: 'Related Alerts', - }), + name: ( + <> + <FormattedMessage + id="xpack.observability.alertDetails.tab.relatedAlertsLabe" + defaultMessage="Related alerts" + /> +   + <BetaBadge size="s" iconType="beta" style={{ verticalAlign: 'middle' }} /> + </> + ), 'data-test-subj': 'relatedAlertsTab', - content: <RelatedAlerts alert={alertDetail.formatted} kuery={relatedAlertsKuery} />, - }); - } + content: <RelatedAlerts alert={alertDetail?.formatted} />, + }, + ]; return ( <ObservabilityPageTemplate diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/assets/illustration_product_no_results_magnifying_glass.svg b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/assets/illustration_product_no_results_magnifying_glass.svg new file mode 100644 index 0000000000000..b9a0df1630b20 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/assets/illustration_product_no_results_magnifying_glass.svg @@ -0,0 +1 @@ +<svg fill="none" height="148" viewBox="0 0 200 148" width="200" xmlns="http://www.w3.org/2000/svg"><g fill="#e6ebf2"><path d="m66.493 121.253c.1447-.064.2618-.178.3302-.321.0685-.143.0837-.305.0431-.459-.1139-.178-.2841-.312-.4834-.382-.1994-.07-.4164-.072-.6166-.004-.1547.096-.2648.25-.3061.428-.0412.177-.0103.364.0861.518.0963.155.2502.265.4278.307.1775.041.3641.01.5189-.087z"/><path d="m46.666 68.1202c.12-.1134.3733-.44.2866-.6-.0866-.16-.5133-.0467-.6666 0-.1534.0466-.22.2666-.0734.4533.1467.1867.3.2933.4534.1467z"/><path d="m45.4062 81.1265c-.057-.0967-.1499-.1671-.2585-.1958s-.2241-.0134-.3215.0425c-.2467.1067-.46.2533-.46.5733 0 .9467.1733 1.16 1.1 1.3334h.0667c-.1215-.0187-.2452-.0187-.3667 0-.0457.0077-.0888.0264-.1256.0544-.0369.0281-.0664.0646-.0861.1065-.0197.042-.0289.0881-.0268.1343.002.0463.0152.0914.0385.1314.0124.0499.0359.0964.0688.1359s.0743.071.1212.0922c.0468.0212.0979.0314.1493.03.0513-.0013.1017-.0144.1474-.0381.12-.095.214-.2188.2733-.36.053.0902.1392.1561.2401.1835s.2086.0142.2999-.0368c.0652-.0378.1204-.0905.1611-.1539.0406-.0634.0656-.1356.0728-.2106.0071-.0749-.0037-.1506-.0316-.2205-.0279-.07-.0721-.1322-.129-.1817-.2266-.22-.1466-.4266-.12-.6666.7067.0466 1-.2667.88-.8667-.0147-.0587-.0325-.1165-.0533-.1733-.1467-.3934-.38-.4867-.7533-.3-.3734.1866-.6.3266-.4867.8733-.2-.1333-.3333-.1867-.4-.2867z"/><path d="m45.4194 80.0002c.091-.047.1596-.1281.1908-.2256.0313-.0975.0226-.2034-.0241-.2944-.0318-.1072-.0995-.2002-.1918-.2633-.0922-.0632-.2034-.0926-.3149-.0834-.0751.011-.1473.0374-.2118.0776-.0645.0401-.1201.0931-.1633.1556-.0431.0626-.0729.1333-.0875.2079s-.0137.1514.0026.2256c.0391.0659.0909.1235.1524.1693s.1315.079.2059.0976.1517.0223.2276.0108c.0758-.0115.1486-.0379.2141-.0777z"/><path d="m104.966 134.133c-.085.15-.112.327-.075.495.037.169.135.318.275.419.147.117.334.172.521.151s.358-.115.476-.262c.117-.146.172-.334.151-.521-.02-.187-.115-.358-.261-.475-.181-.074-.379-.095-.572-.061-.192.034-.371.123-.515.254z"/><path d="m47.0795 69.0398c-.0635.0231-.121.0603-.1681.1087-.0471.0485-.0826.1071-.1037.1713-.0212.0641-.0276.1323-.0186.1993s.0331.1311.0704.1874c.0657.1.165.1732.28.2062s.238.0237.3467-.0262c.1008-.0745.1726-.1818.203-.3034.0305-.1216.0176-.2501-.0364-.3633-.0525-.0995-.1422-.1743-.2496-.208-.1073-.0337-.2237-.0237-.3237.028z"/><path d="m46.9868 64.0001c.12-.0866.3466-.4.28-.5067-.0667-.1066-.4267-.1266-.58-.1-.1534.0267-.2467.26-.14.4867.0126.0441.0363.0842.069.1164.0326.0322.0729.0555.1172.0675.0442.0121.0908.0125.1352.0013.0445-.0111.0853-.0336.1186-.0652z"/><path d="m51.4726 108c-.1429.128-.233.305-.2527.496s.0323.382.146.537c.1749.138.3948.205.6168.189.2219-.016.4299-.114.5832-.276.064-.057.1152-.126.1503-.205.0351-.078.0532-.162.0532-.248s-.0181-.17-.0532-.248c-.0351-.079-.0863-.148-.1503-.205-.0659-.08-.1479-.145-.2406-.19-.0926-.046-.1939-.071-.2971-.075s-.2061.014-.3018.053c-.0958.039-.1823.097-.2538.172z"/><path d="m53.0133 100.154c.0244-.011.0457-.028.0624-.048.0166-.021.028-.046.0332-.072s.0041-.0528-.0032-.0784c-.0074-.0256-.0208-.0491-.039-.0684-.0096-.0255-.0255-.0482-.0462-.0658-.0207-.0177-.0456-.0297-.0723-.0351-.0267-.0053-.0544-.0038-.0803.0045-.0259.0084-.0493.0232-.0679.043-.0251.0112-.047.0284-.0638.0501-.0168.0216-.028.0471-.0327.0741-.0046.0271-.0024.055.0062.081.0087.026.0237.049.0436.068.0118.023.0286.042.0491.058.0206.015.0443.025.0694.029.0252.005.051.004.0755-.003s.0471-.02.066-.037z"/><path d="m49.7333 91.7797c.0712.1903.1605.3733.2667.5466-.0497.0839-.0942.1707-.1334.26-.0723.1653-.0945.3483-.0637.5261.0309.1778.1133.3426.2371.4739.1261.0971.2731.1634.4294.1935s.3174.0232.4706-.0201c-.0345.1648-.0345.3351 0 .5l.3133.1466c-.4067-1.12-.7667-2.2666-1.1067-3.42-.0638.0231-.1243.0545-.18.0934-.1104.0748-.1931.1839-.2353.3104-.0422.1266-.0415.2635.002.3896z"/><path d="m52.5402 102.127c.0525.017.1095.015.1608-.006.0513-.02.0935-.059.1192-.108.0172-.061.0156-.126-.0046-.187s-.0581-.114-.1087-.153c-.0934-.06-.24.06-.2867.154-.0467.093.0067.266.12.3z"/><path d="m52.7327 106.413c.0843-.096.1392-.214.1581-.341.0189-.126.001-.256-.0515-.372-.0264-.041-.0614-.077-.1025-.104-.0412-.026-.0876-.044-.1361-.052-.0485-.007-.0981-.005-.1455.009-.0473.013-.0914.036-.1292.067-.3133.213-.4533.447-.36.613.1.096.2238.163.3587.195s.2758.027.408-.015z"/><path d="m53.0528 104.127c.0915-.145.1338-.315.1206-.486-.0132-.17-.0813-.332-.1939-.461-.1356-.063-.2859-.087-.4342-.069s-.2888.076-.4058.169c-.0758.095-.117.212-.117.333 0 .122.0412.239.117.334.3533.32.7133.393.9133.18z"/><path d="m44.4995 73.3736c-.1873.0998-.331.2653-.4036.4647-.0726.1993-.069.4185.0102.6153.0828.1942.233.3518.423.4437s.4068.1119.6104.0563c.2009-.1305.3546-.3221.4386-.5464.084-.2244.0938-.4698.028-.7002-.1121-.1809-.2865-.3146-.4903-.3759-.2038-.0614-.4229-.0463-.6163.0425z"/><path d="m54.3862 101.46c-.1734-.326-.34-.666-.5067-.986-.2533.16-.3533.393-.2533.593.0915.116.2066.211.3376.278.1311.068.275.107.4224.115z"/><path d="m55.1802 106.173c-.12.26-.22.527.0867.667.3066.14.4733-.034.6-.274-.0117-.075-.0402-.146-.0832-.209-.0431-.062-.0997-.115-.1656-.152-.066-.038-.1396-.061-.2154-.066s-.1518.006-.2225.034z"/><path d="m51.0395 95.3332c-.12.0466-.1667.12-.1133.2466.0533.1267.1266.16.2533.1134.0245-.0065.0472-.0182.0667-.0344.0194-.0161.0351-.0364.0459-.0592.0108-.0229.0164-.0478.0166-.0731.0001-.0253-.0053-.0504-.0159-.0733-.0466-.12-.12-.1734-.2533-.12z"/><path d="m55.1867 108.2c-.0344.037-.0608.08-.0772.128-.0165.048-.0227.098-.0183.149.0044.05.0193.099.0438.143s.058.083.0984.113c.0419.047.0932.085.1507.11.0574.026.1197.039.1826.039.063 0 .1252-.013.1826-.039.0575-.025.1088-.063.1507-.11.038-.04.0672-.088.0859-.14s.0264-.107.0226-.162c-.0037-.055-.0189-.109-.0444-.158-.0256-.049-.061-.092-.1041-.127-.047-.042-.1021-.074-.162-.094-.0598-.02-.1231-.028-.186-.023-.063.005-.1243.023-.1802.052s-.1053.07-.1451.119z"/><path d="m55.6861 110.907c.0895.086.209.135.3333.135.1244 0 .2439-.049.3334-.135.0759-.082.1181-.189.1181-.3s-.0422-.219-.1181-.3c-.0325-.045-.0742-.083-.1225-.111-.0482-.028-.1019-.046-.1573-.051-.0555-.006-.1116 0-.1645.018-.0529.017-.1014.046-.1424.084-.0523.035-.0963.081-.1292.135s-.0539.114-.0614.177c-.0076.062-.0017.126.0174.186s.0509.115.0932.162z"/><path d="m54.3328 104.666c-.0296.04-.0501.086-.0602.134-.01.049-.0093.099.0022.148.0114.048.0333.093.064.132.0308.039.0697.071.114.093.3134.213.5867.253.6667.107.0513-.128.0672-.267.0461-.404-.0211-.136-.0785-.263-.1661-.37-.1152-.036-.2381-.04-.3555-.012s-.225.088-.3112.172z"/><path d="m37.2201 85.493c-.16-.3-.3533-.2934-.5133-.2067s-.3.26-.16.4333c.14.1734.34.22.4466.1667.1067-.0533.1734-.2867.2267-.3933z"/><path d="m38.3067 92.5403c-.133.0676-.2395.178-.3023.3134-.0629.1354-.0785.2879-.0443.4332.0751.1437.1955.2588.3424.3274.147.0687.3124.0872.4709.0526.26-.1067.3333-.5267.16-.9066-.0624-.104-.1592-.183-.2736-.2231-.1145-.0402-.2394-.0391-.3531.0031z"/><path d="m37.9193 89.1732c-.2733-.1066-.5133-.2733-.7467 0-.0713.0745-.1111.1736-.1111.2767s.0398.2022.1111.2767c.0509.0575.1135.1033.1837.1344.0702.031.1463.0466.223.0456.3333-.0867.38-.3667.34-.7334z"/><path d="m44.9994 100.606c-.26 0-.48.56-.3.794.18.233.6.293.74.106.14-.186-.1733-.84-.44-.9z"/><path d="m43.2135 95.5601-.2066.22c-.1273-.1332-.2631-.258-.4067-.3734-.0407-.0489-.0916-.0883-.1492-.1153-.0576-.0271-.1205-.0411-.1841-.0411-.0637 0-.1265.014-.1842.0411-.0576.027-.1085.0664-.1492.1153-.1093.0939-.1852.2206-.2165.3612-.0312.1406-.0161.2875.0432.4188.0516.1288.1462.2357.2678.3026.1215.0668.2625.0895.3989.0641.2014-.0187.4003-.059.5933-.12.0669.0714.1406.1361.22.1933.1426.1112.3151.1775.4955.1906s.3606-.0277.5178-.1172c.1411-.1137.2508-.2616.3185-.4296.0677-.1681.0912-.3507.0682-.5304-.0192-.1372-.0909-.2615-.2-.3467-.0891-.0754-.1931-.1312-.3051-.1638-.112-.0327-.2297-.0414-.3453-.0257-.1157.0157-.2267.0555-.326.1169-.0993.0613-.1845.1429-.2503.2393z"/><path d="m46.1728 105.334c-.0372.033-.0669.075-.0872.121-.0204.045-.0309.095-.0309.145 0 .051.0105.1.0309.146.0203.046.05.087.0872.121.0288.035.0643.063.1043.083.0401.021.0838.033.1286.036.0449.003.0899-.003.1323-.018.0425-.014.0816-.037.1149-.068.0421-.025.0784-.059.1065-.1.0282-.04.0476-.086.057-.134.0094-.049.0086-.098-.0023-.146-.011-.048-.0318-.094-.0613-.133-.0336-.043-.0756-.079-.1235-.105s-.1006-.042-.1549-.047c-.0544-.005-.1091.001-.161.018-.0518.017-.0997.045-.1406.081z"/><path d="m40.3331 86.1863c0-.3533-.2133-.4933-.5667-.5-.1933-.8267-.6666-.9267-1.3333-.3133-.0575-.014-.1134-.0342-.1667-.06-.0867-.0521-.19-.0693-.289-.0483-.0989.021-.1862.0788-.2443.1616-.0652.0637-.1086.1462-.1242.236s-.0024.1821.0375.264c.1534.4667.36.5667.82.38l1.04-.4c.2867.62.3667.64.8267.28z"/><path d="m41.3329 88.8003c-.0319-.115-.0894-.2211-.1683-.3105s-.1771-.1597-.2871-.2056c-.1101-.0459-.2291-.0661-.3481-.0593-.1191.0069-.235.0408-.339.099-.104.0583-.1935.1394-.2615.2373-.0681.0979-.113.21-.1314.3278s-.0098.2383.0252.3522c.035.114.0955.2185.1768.3057.1421.1708.3439.281.5643.3083.2205.0272.443-.0306.6224-.1616.0713-.0465.1322-.1074.1787-.1787.0465-.0714.0777-.1516.0914-.2357.0138-.084.01-.17-.0113-.2525-.0212-.0824-.0594-.1596-.1121-.2264z"/><path d="m41.9465 92.0001c-.0958-.1538-.2409-.2705-.4117-.331-.1707-.0606-.357-.0614-.5283-.0023-.1425.0952-.2468.2378-.2944.4025-.0475.1647-.0354.3409.0344.4975.1467.3067.4267.3667.82.1733.2667-.1333.4734-.5333.38-.74z"/><path d="m42.8393 97.9398c-.0974-.0154-.1971.0034-.2822.0533-.0851.0498-.1503.1276-.1845.22-.0116.0685-.0045.1389.0205.2037s.067.1216.1215.1646c.0546.043.1197.0705.1886.0796.0688.0091.1389-.0005.2027-.0279.0581-.0406.1043-.0958.134-.1602.0297-.0643.0418-.1354.035-.2059s-.0322-.138-.0736-.1955-.0973-.1029-.162-.1317z"/><path d="m44.3393 66.8467c.1-.0467.0933-.3.0533-.3667s-.1866-.1667-.2866-.1133c-.0485.0311-.0853.0774-.1046.1317-.0194.0543-.0201.1135-.0021.1683.0467.0733.2467.2333.34.18z"/><path d="m43.0463 75.7404c.1066.1067.3933.3534.54.26.1466-.0933.04-.4866 0-.6-.04-.1133-.2867-.0866-.4067-.04-.0426.0033-.0833.0189-.1171.0449s-.0594.0613-.0735.1016c-.0142.0403-.0163.0838-.0061.1253s.0322.0791.0634.1082z"/><path d="m42.7535 71.9601c.1419-.1152.2376-.2776.2694-.4576.0319-.18-.0022-.3655-.096-.5223-.1135-.1308-.2706-.2158-.4421-.2392-.1715-.0235-.3456.0162-.49.1116-.1444.0955-.2492.2402-.2948.4071-.0456.167-.0289.3449.0469.5004.1071.1599.2732.2707.462.3082.1887.0375.3845-.0014.5446-.1082z"/><path d="m41.2198 84.8734c-.2267.1133-.3067.3-.1467.48s.3133.46.56.3267c.2467-.1334.1133-.4867.0867-.6667-.0267-.18-.2934-.2467-.5-.14z"/><path d="m40.4998 81.8067c-.1534.1298-.2604.3061-.3049.5021s-.0241.4012.0582.5845c.0623.0916.1427.1694.2363.2286.0936.0591.1984.0984.3079.1152.1094.0169.2212.0111.3283-.0171s.2072-.0781.2942-.1467c.0867-.0389.1641-.0959.227-.1672s.1098-.1553.1376-.2462.0358-.1867.0235-.281c-.0123-.0942-.0446-.1848-.0948-.2656-.1241-.1972-.3193-.3391-.5453-.3962-.2259-.0571-.4651-.025-.668.0896z"/><path d="m45.086 72.0931c.1475-.0869.2614-.2211.3231-.3808.0617-.1598.0677-.3356.0169-.4992-.0591-.118-.1608-.2092-.2846-.255s-.2604-.0428-.3821.0084c-.1378.0846-.2402.2165-.2881.371-.0478.1546-.0378.3212.0281.469.0179.0585.0477.1128.0874.1594.0398.0465.0887.0844.1437.1113s.115.0422.1761.0449c.0612.0028.1223-.0071.1795-.029z"/><path d="m45.5861 69.7262c.0263-.021.0441-.0507.0502-.0838.0061-.033.0001-.0672-.0168-.0962 0-.04-.14-.0466-.1667 0-.0136.0088-.0253.0201-.0345.0334s-.0156.0282-.0191.044c-.0034.0158-.0036.0321-.0007.048s.0089.0311.0176.0446c.0088.0136.0201.0253.0334.0345s.0282.0157.044.0191.0321.0036.048.0007.0311-.0089.0446-.0176z"/><path d="m43.7065 77.4263c.1036.1215.2474.2019.4052.2263.1579.0245.3192-.0085.4548-.093.3866-.22.5-.4667.34-.7667-.0917-.1545-.2343-.2724-.4034-.3332s-.3542-.0609-.5233-.0001c-.1428.1109-.2463.2645-.2955.4384-.0492.174-.0414.3591.0222.5283z"/><path d="m40.7993 74.7869c.12-.1133.3867-.4467.2933-.5867-.0933-.14-.4933-.0866-.6666-.0466-.1734.04-.2134.3466-.0934.52.12.1733.3334.2599.4667.1133z"/><path d="m38.3535 72.9266c0 .04.18.1334.22.1067s.2467-.14.2-.2867c-.0466-.1467-.2667-.12-.3-.0867-.0722.0706-.1151.1659-.12.2667z"/><path d="m38.5999 76.4801c.046.1061.1322.1895.2398.2321.1075.0425.2275.0405.3336-.0055.1061-.0459.1895-.1322.2321-.2397.0425-.1075.0405-.2275-.0055-.3336-.0669-.0996-.1633-.1756-.2757-.2175-.1125-.0418-.2352-.0474-.3509-.0158-.0953.0583-.1657.1498-.1977.2568s-.0233.2221.0243.3232z"/><path d="m40.9266 65.8929c.0734-.04.0734-.2067.0534-.3s-.1734-.1933-.2867-.1333-.0533.2733 0 .36c.0288.032.0656.0557.1066.0686.0411.0129.0849.0145.1267.0047z"/><path d="m37.9998 71.1003c.1113.0157.2246-.0095.3189-.0707.0943-.0613.1634-.1545.1944-.2626.0428-.0806.052-.1747.0258-.2621-.0262-.0873-.0858-.1608-.1658-.2046-.2933-.1933-.52-.0466-.7667.16.06.3.0467.6067.3934.64z"/><path d="m40.7593 79.3996c.1105-.1034.1845-.2398.211-.3889.0264-.149.0038-.3026-.0644-.4378-.1309-.0746-.2832-.1027-.4321-.0797s-.2856.0957-.3879.2064c-.0445.0933-.0591.1981-.0416.3001.0175.1019.0661.1958.1393.269.0731.0731.1671.1217.269.1392s.2067.0029.3-.0416z"/><path d="m38.3734 80.6665c.2867-.16.3667-.38.2333-.6666-.0815-.1728-.2236-.3097-.3993-.3846-.1758-.075-.3729-.0828-.554-.0221-.1239.1051-.2052.2519-.2285.4127-.0234.1608.0129.3246.1018.4606.0878.1365.2251.2336.383.2709s.3241.0119.4637-.0709z"/><path d="m45.6061 107.934c-.1334 0-.2867.266-.4134.433-.0197.017-.0355.038-.0463.061-.0109.024-.0165.05-.0165.076s.0056.051.0165.075c.0108.023.0266.044.0463.061.107.058.2298.08.3501.062s.2314-.075.3166-.162c.0333-.055.0531-.116.0579-.18s-.0055-.128-.0302-.188c-.0247-.059-.0631-.111-.1119-.153-.0488-.041-.1068-.071-.1691-.085z"/><path d="m79.2334 128.127c-.1266-.254-.4666-.32-.8466-.154-.28.134-.4867.507-.3934.72.0874.159.2232.285.3876.361.1644.075.3486.096.5258.059.0426-.017.083-.039.12-.066-.0331.126-.0331.26 0 .386.1.329.3093.614.5933.807.1003.077.1766.181.22.3.0114.098.0472.191.104.271s.1328.145.2211.188c.0882.043.1859.063.284.059.0981-.005.1935-.034.2776-.085-.0065.058.0052.116.0333.167.0794.095.1901.159.3124.18.1222.021.2479-.003.3543-.067.1279-.049.2319-.146.2902-.27.0584-.124.0666-.266.0231-.396-.0291-.053-.0686-.1-.1162-.138-.0475-.038-.1021-.065-.1605-.081-.0584-.017-.1195-.021-.1796-.013s-.1181.028-.1704.058c-.0191-.153-.0789-.298-.1733-.42-.1493-.158-.2306-.369-.2267-.586 0-.36-.2533-.58-.5-.78-.1357-.077-.2902-.113-.4457-.106s-.306.057-.4343.146l-.0666.06c.0687-.197.0567-.413-.0334-.6z"/><path d="m89.2063 134.106c.1.2.4133.14.5533.114.0453-.004.0891-.018.1281-.041.0391-.023.0724-.055.0974-.093s.0412-.081.0471-.126c.006-.045.0017-.09-.0126-.134-.06-.213-.34-.4-.5-.28s-.4133.36-.3133.56z"/><path d="m93.4526 132.774c-.0796.033-.1511.084-.2098.147-.0586.064-.1029.139-.1301.221-.0271.082-.0364.169-.0272.255.0091.086.0366.169.0804.243.1734.354.5134.52.7734.374.1715-.108.3012-.271.3679-.463.0666-.191.0662-.4-.0013-.591-.0959-.128-.235-.218-.3918-.252-.1567-.035-.3206-.011-.4615.066z"/><path d="m93.6128 135.74c-.12.193.0933.427.1933.533.0274.036.0627.065.1032.085.0404.02.085.031.1301.031.0452 0 .0898-.011.1302-.031.0405-.02.0758-.049.1032-.085.1467-.166.16-.5 0-.573s-.54-.147-.66.04z"/><path d="m90.1928 131.573c-.0227-.064-.0594-.122-.1074-.169-.048-.048-.1061-.085-.1701-.107-.0639-.022-.1321-.03-.1995-.023-.0673.008-.1322.03-.1897.066-.0752.04-.138.101-.1818.174s-.067.157-.067.243c0 .085.0232.169.067.242.0438.074.1066.134.1818.174-.0062.06.0083.121.041.172s.0817.089.139.108c.1415.02.2851.02.4267 0 .0392-.063.0626-.135.0684-.209s-.0062-.149-.0351-.217c.041-.068.0648-.145.0695-.224.0046-.079-.0101-.158-.0428-.23z"/><path d="m75.9995 127.926c.34-.167.4867-.467.3533-.72-.1094-.17-.2722-.299-.4629-.366-.1906-.068-.3983-.07-.5904-.007-.115.099-.1929.234-.2208.383-.0278.149-.004.303.0675.437.0306.08.079.153.1414.212.0625.059.1375.104.2195.13s.1688.034.254.022c.0853-.012.1667-.043.2384-.091z"/><path d="m66.7799 124.22c.0945.071.204.119.32.14.28-.146.3734-.406.2334-.546-.0545-.051-.1186-.09-.1885-.115-.0698-.025-.1441-.036-.2182-.032-.1933.033-.26.4-.1467.553z"/><path d="m71.5127 123.413c.3-.106.5533-.446.48-.666-.1467-.46-.6667-.454-.92-.44-.2534.013-.38.613-.14.96.0603.093.1536.16.2612.187.1075.027.2214.013.3188-.041z"/><path d="m114.093 132.72c-.3.113-.22.366-.167.606.26.12.494.14.667-.126.021-.06.028-.124.018-.187-.009-.063-.034-.122-.071-.173-.06-.054-.132-.093-.209-.113-.078-.021-.159-.024-.238-.007z"/><path d="m113.56 134.593c.113.073.206.167.353.147s.2-.234.087-.4c-.115-.152-.285-.253-.474-.28-.032-.004-.066 0-.096.012-.031.011-.059.029-.082.053-.022.024-.039.053-.049.084-.009.032-.011.065-.006.097.021.065.056.124.102.173.046.05.102.089.165.114z"/><path d="m116.073 130.46c.353.047.493-.187.626-.487-.093-.113-.173-.233-.26-.32l-.666.147c-.043.094-.061.197-.054.3 0 .047.008.094.025.137.018.044.044.084.077.117.032.034.072.06.115.078.043.019.09.028.137.028z"/><path d="m121.213 128.873c.033 0 .14 0 .16-.053.02-.054 0-.127 0-.16-.026-.025-.061-.039-.097-.039s-.07.014-.097.039c-.023.033-.033.073-.027.113s.028.076.061.1z"/><path d="m114.58 132.12c.201-.015.389-.103.528-.248.139-.146.219-.337.226-.538-.044-.2-.16-.377-.325-.498s-.369-.177-.573-.159c-.204.019-.394.112-.534.261-.141.149-.222.344-.228.549-.034.347.44.6.906.633z"/><path d="m117.56 135.086c-.046.048-.072.111-.072.177s.026.129.072.177c.133.126.28-.06.347-.127.066-.067 0-.227-.034-.24-.049-.023-.103-.034-.158-.032-.054.003-.108.018-.155.045z"/><path d="m118.033 130.46c0-.147 0-.433-.114-.473-.074-.01-.15-.002-.221.023-.071.026-.135.068-.186.123-.06.087-.053.44.054.507.106.067.433-.113.467-.18z"/><path d="m107.713 134.32c-.029.052-.047.11-.052.17-.005.059.002.119.021.176.02.056.051.108.092.152s.09.078.145.102c.194.093.407.2.534 0 .206-.334.186-.62 0-.76-.057-.045-.123-.076-.194-.092-.07-.016-.143-.016-.214-.001-.07.015-.137.046-.194.089-.057.044-.105.1-.138.164z"/><path d="m107.333 132.207c-.051.139-.053.292-.007.433.045.142.137.264.26.347.048.028.101.046.155.052.055.006.11 0 .163-.017.052-.017.1-.045.141-.082s.073-.083.095-.133c.233-.354.253-.627.073-.767-.145-.069-.309-.09-.467-.06s-.303.109-.413.227z"/><path d="m103.893 136.267c-.031.044-.053.095-.063.149s-.009.11.005.163c.013.053.038.103.072.145.034.043.077.078.126.103.109.062.236.084.359.059.123-.024.232-.093.308-.193.053-.108.065-.231.035-.347-.031-.117-.102-.218-.202-.286-.055-.031-.116-.05-.178-.056-.063-.006-.126.001-.186.02-.06.02-.116.051-.163.093-.048.042-.086.092-.113.15z"/><path d="m112 131.2c.155.096.341.13.52.095.179-.034.338-.135.447-.282.096-.209.112-.446.046-.666l-1.4.213c.015.128.057.252.123.362.067.111.157.206.264.278z"/><path d="m110.253 133.193c.051.033.108.055.169.064.06.009.121.005.179-.012.059-.018.113-.047.158-.087.046-.04.083-.089.107-.145.267-.413.254-.7-.046-.893-.147-.059-.308-.069-.461-.031s-.291.124-.393.244c-.037.156-.031.319.02.471s.143.287.267.389z"/><path d="m50.8796 97.4535c.0122.0368.0122.0765 0 .1133-.0559.0213-.1095.0481-.16.08-.0734-.1-.2267-.12-.4867.04-.0623.0246-.1186.0623-.1651.1107-.0465.0483-.082.106-.1041.1693-.0221.0632-.0304.1305-.0242.1972.0062.0668.0267.1314.0601.1895.0604.1218.1567.2222.2759.2878.1191.0655.2555.093.3907.0789.0506.0557.115.0971.1867.1199.2467.0734.4867.2534.6667.1534.0626-.0296.1174-.0732.1602-.1276s.0724-.118.0864-.1857c.0759-.1286.1064-.2788.0867-.4267-.0328-.1366-.0917-.2657-.1733-.38.0067-.071.0067-.1424 0-.2133-.0334-.2667-.0934-.6667-.46-.6134-.3667.0534-.3667.2867-.34.4067z"/><path d="m46.8258 73.2064c-.3 0-.6133 0-.6133.4334-.0089.1105.0208.2208.084.3119.0632.0912.156.1576.2626.1881.0451.0204.0939.031.1434.031.0494 0 .0983-.0106.1433-.031.1467-.1667.2667-.36.4133-.56.2344-.0052.4637-.0694.6667-.1867 0-.2933 0-.5933 0-.8867-.1614-.1572-.3749-.2497-.6-.26-.4-.02-.5.18-.5.96z"/><path d="m47.9061 84.4203c.0759.099.1827.1699.3035.2013s.2486.0215.3631-.028c0 0 0 0 .04-.0467-.0533-.28-.1066-.56-.1533-.84-.1384-.0467-.2883-.0467-.4267 0-.058.0361-.1079.084-.1462.1405-.0383.0566-.0643.1206-.0763.1879-.0119.0673-.0096.1364.007.2027.0165.0663.0468.1284.0889.1823z"/><path d="m47.0396 76.4866c.0635-.006.1246-.0273.1781-.0622.0534-.0348.0975-.0821.1286-.1378.0933-.28-.0667-.4534-.3467-.56-.2.1266-.4466.2466-.3333.5466.0428.0608.0984.1114.1629.1483.0645.0368.1364.0591.2104.0651z"/><path d="m49.2461 93.8936c.0866.1533.2066.3.4266.2133.1124-.0643.195-.1703.2299-.2951.035-.1247.0194-.2582-.0432-.3716-.1611-.0271-.3256-.0271-.4867 0-.2.0734-.2266.2734-.1266.4534z"/><path d="m49.0668 86.833c-.0481.1125-.0599.2373-.0336.3569.0263.1195.0894.2278.1803.3098-.0467-.24-.1067-.4534-.1467-.6667z"/><path d="m48.666 90.2132c.1732.0912.3731.1185.5645.077.1914-.0414.3621-.149.4821-.3037.0428-.0986.0611-.2061.0534-.3133-.04-.14-.0867-.2734-.12-.4134-.0832-.1646-.2264-.2911-.4-.3533-.1719-.0707-.3623-.083-.5418-.0348-.1796.0482-.3382.1541-.4516.3015-.22.2666.0267.7466.4134 1.04z"/><path d="m47.5263 77.6201c-.4067.0933-.8733.2466-.9733.74-.0179.16-.0017.3219.0475.4752s.1303.2944.238.4141c.1077.1196.2396.2151.3868.2802.1473.065.3067.0981.4677.0971.0683-.0061.1371.0062.1992.0355s.1153.0746.1541.1312c-.0867-1.0667-.14-2.1333-.1667-3.2133-.1489.3353-.2672.6833-.3533 1.04z"/><path d="m50.9331 102.426c.0214-.01.0403-.024.0553-.043.015-.018.0257-.039.0315-.062.0057-.023.0063-.047.0016-.07-.0046-.024-.0143-.046-.0284-.065-.06-.073-.1867-.213-.3333-.153-.1467.06-.06.287 0 .32s.1533.147.2733.073z"/><path d="m50.6663 95.3331c-.009-.0977-.0519-.1893-.1213-.2587s-.161-.1123-.2587-.1213c-.2334 0-.3.14-.28.62.0546.0454.1202.0757.1902.0879.0699.0122.1419.0058.2087-.0185.0667-.0242.1259-.0656.1718-.1199.0458-.0542.0766-.1196.0893-.1895z"/><path d="m49.5996 101.153c.0087.034.0246.066.0467.093.0424.055.097.099.1595.129s.1311.046.2005.045c.1266 0 .2533.066.4333 0 .1324-.064.2483-.157.3386-.273.0902-.115.1523-.25.1814-.394.0155-.127-.0028-.255-.0532-.372s-.1309-.219-.2335-.295c-.091-.06-.1964-.0949-.3054-.1007-.109-.0059-.2176.0177-.3146.0677-.1093.047-.2055.12-.2799.213s-.1248.203-.1467.32c-.0623-.023-.1311-.023-.1934 0-.3066.074-.6666.067-.9066.38l.04.454c-.3134.28-.4667.666-.3267.86.0528.079.131.138.2219.167s.189.027.2781-.007c.3667-.12.4467-.26.4133-.72.18-.24.3134-.387.4467-.567z"/><path d="m48.8398 104.667c.2467-.24.22-.527-.08-.82-.0357-.052-.0817-.097-.1354-.13-.0536-.034-.1135-.056-.1761-.065-.0626-.01-.1264-.006-.1875.01-.0611.017-.1182.045-.1676.085-.1068.138-.1743.303-.1955.477-.0211.173.005.35.0755.51.2733.28.5666.253.8666-.067z"/><path d="m62.7397 119.267c0-.134-.2466-.174-.32-.154-.0518.019-.0976.051-.1328.093s-.0584.093-.0672.147c0 .107.18.174.2667.174s.2667-.134.2533-.26z"/><path d="m48.2866 95.6997c-.0182.015-.0328.0339-.0428.0552-.0101.0213-.0153.0446-.0153.0681 0 .0236.0052.0469.0153.0682.01.0213.0246.0402.0428.0552.06.0666.2333.22.3333.1333s0-.28 0-.3133c0-.0334-.2133-.16-.3333-.0667z"/><path d="m48.1469 96.8662c.1133 0 .28.1667.16.2067s-.1867.1867-.0934.4933c.0101.0655.0337.1281.0694.1839.0357.0557.0828.1034.1381.1398.0553.0365.1176.0609.1829.0718.0653.0108.1322.0078.1963-.0088.1787-.0428.3334-.1542.4307-.3101.0973-.156.1294-.3439.0893-.5232-.08-.38-.3066-.4-.3333-.6667s-.18-.3133-.4467-.3533-.6667-.0734-.7067.3c-.04.3733.2.4666.3134.4666z"/><path d="m48.3197 86.1664c.2667-.1533.2734-.5533 0-.9666-.0873-.0958-.2031-.1611-.3303-.1863s-.2591-.009-.3763.0463c-.1107.0999-.1939.2265-.2417.3677-.0479.1412-.0588.2923-.0317.4389.1081.1503.2625.261.4395.3152.177.0541.3668.0488.5405-.0152z"/><path d="m46.9933 88.3529c-.1616-.1865-.3718-.3246-.6072-.3987-.2354-.0742-.4867-.0815-.7261-.0213-.1762.1197-.3013.301-.3507.5083-.0493.2072-.0193.4255.084.6117.1248.1837.3075.3203.519.3879.2115.0677.4395.0626.6477-.0145.1972-.0879.3526-.249.4334-.4492.0809-.2002.0808-.424-.0001-.6242z"/><path d="m42.96 78.9199c.0312-.1742.0312-.3525 0-.5267-.0734-.2467-.4334-.2933-.6667-.14-.0454.0318-.0839.0724-.1133.1194-.0293.047-.049.0994-.0577.1542-.0087.0547-.0063.1106.007.1644.0134.0538.0374.1044.0707.1487.0921.1085.2224.1774.364.1923.1415.0149.2833-.0254.396-.1123z"/><path d="m45.5793 96.8004c-.06.2266.0667.3266.5333.42.0519-.0417.0925-.0957.1182-.1571.0257-.0613.0356-.1281.029-.1943s-.0296-.1297-.067-.1847c-.0373-.0551-.0878-.1-.1468-.1306-.0943-.0184-.192-.004-.2769.0409-.085.0449-.1519.1175-.1898.2058z"/><path d="m42.0666 80.4133c.0728.1032.1821.1751.3058.2011.1236.0259.2526.0041.3608-.0611.0955-.0697.1619-.1722.1865-.2879.0246-.1156.0057-.2363-.0531-.3388-.0749-.1009-.1845-.1704-.3077-.1951-.1232-.0246-.2511-.0026-.359.0618-.0975.0665-.1658.1679-.1906.2833s-.0043.236.0573.3367z"/><path d="m46.0329 92.9536c-.0538-.0171-.1106-.0227-.1668-.0167-.0561.0061-.1104.0239-.1593.0521s-.0914.0663-.1248.1119c-.0334.0455-.0569.0975-.0691.1527-.0189.0812-.0185.1657.0012.2468.0197.081.0581.1563.1122.2199.26.2066.52.0733.7466-.1467-.0066-.24-.04-.54-.34-.62z"/><path d="m45.3334 91.0667c.24-.2067.12-.4667 0-.7467-.2934 0-.6-.1-.7267.2467-.0065.033-.0065.067 0 .1-.1339-.0477-.2761-.0682-.418-.0602-.142.0081-.2809.0444-.4087.1069-.0489.0333-.0908.0761-.123.1259-.0323.0497-.0544.1053-.0649.1636-.0106.0584-.0094.1182.0034.1761s.037.1126.0712.161c.0727.1525.2005.2717.3576.3336.1572.0619.332.0618.4891-.0002.0939-.054.1748-.128.237-.2167s.1042-.19.123-.2967c.0774.0311.1622.0389.244.0223.0817-.0166.1568-.0568.216-.1156z"/><path d="m47.4735 93.3731c.6667.72 1.2533.3467 1.6533-.16.1355-.1683.2106-.3773.2134-.5933-.0065-.1434-.0439-.2837-.1094-.4114-.0656-.1277-.1579-.2398-.2706-.3286-.2467-.2267-.2534-.5667-.5667-.6667-.2801-.1068-.5875-.119-.8752-.0347s-.5399.2604-.7181.5014c-.1199.1416-.2023.311-.2395.4927-.0373.1818-.0283.3699.0261.5473.1267.36.62.3467.8867.6533z"/><path d="m43.5266 82.7732c.28-.16.32-.4667.1066-.8467-.2133-.38-.46-.4333-.6666-.2933-.1269.1208-.2251.2686-.2874.4325-.0623.1638-.0871.3395-.0726.5141.0967.1473.2477.2502.4201.2865.1724.0362.3521.0027.4999-.0931z"/><path d="m45.4528 86.9932c.0927-.0811.161-.1864.1975-.3041s.0397-.2432.0091-.3625c-.027-.0603-.0666-.114-.1163-.1576-.0496-.0436-.108-.0759-.1713-.0949-.0632-.019-.1298-.0241-.1952-.0151-.0654.0091-.1281.0321-.1838.0676-.1047.0601-.1848.1553-.2262.2687-.0415.1134-.0417.2378-.0005.3513.0825.098.1895.1724.3101.2157s.2505.054.3766.0309z"/><path d="m43.8333 92.6666c-.17.091-.3018.2399-.3715.4197-.0697.1797-.0727.3785-.0085.5604.1122.1698.2835.2918.4806.3423.1972.0505.4061.0259.5861-.069.064-.0384.1199-.089.1643-.1491.0444-.06.0766-.1282.0945-.2007.018-.0725.0214-.1478.0102-.2217-.0112-.0738-.037-.1447-.0757-.2085-.1533-.32-.6333-.5734-.88-.4734z"/><path d="m43.1398 86.0331c.1133.2133.78.2866 1.0467.1133.0919-.0898.1559-.2043.1843-.3297s.02-.2563-.0243-.3769c-.0739-.1513-.2022-.2691-.3592-.3297-.1571-.0606-.3312-.0595-.4875.003-.1598.0853-.2839.2248-.3499.3935s-.0696.3554-.0101.5265z"/><path d="m44.1659 89.0466c.1152-.0436.2189-.113.3031-.2029.0843-.0899.1468-.1978.1828-.3156.0361-.1178.0447-.2422.0252-.3638-.0196-.1216-.0667-.2372-.1377-.3377-.124-.1975-.3198-.3391-.5461-.395-.2264-.056-.4656-.0219-.6673.0949-.1678.1612-.2787.3726-.3159.6022-.0373.2297.001.4652.1093.6712.0498.0878.1172.1643.198.2248.0808.0606.1732.1037.2715.1269.0982.0232.2002.0258.2995.0077s.1938-.0565.2776-.1127z"/><path d="m93.2926 134.153c.0333-.234-.38-.667-.6667-.707-.2866-.04-.5266-.08-.6666.253-.14.334-.2267.474-.3467.714-.0483.036-.0875.083-.1145.136-.027.054-.0411.114-.0411.174s.0141.119.0411.173.0662.101.1145.137c.0973.076.2192.113.3423.104.123-.008.2386-.062.3244-.151.1195-.118.2596-.213.4133-.28.1472-.013.286-.074.3946-.174s.1808-.234.2054-.379z"/><path d="m98.306 134.3v.033c-.0667.267.18.353.3733.433.2667-.113.4134-.293.2934-.56-.04-.093-.2267-.206-.3067-.18l-.1533.08c.0215-.041.0351-.086.04-.133-.0124-.058-.0372-.112-.0726-.159-.0355-.047-.0808-.086-.1328-.114s-.1095-.044-.1684-.048c-.059-.003-.118.006-.1729.028-.0536.032-.0986.077-.1311.13s-.0516.114-.0556.176c.0064.073.0295.144.0677.206.0382.063.0903.115.1523.154.0451.013.0925.015.1387.007.0461-.008.0899-.026.128-.053z"/><path d="m96.28 137.18c.0411-.048.0717-.104.0897-.165s.0231-.124.0149-.187-.0294-.123-.0623-.177c-.033-.055-.0769-.101-.1289-.137-.1039-.063-.2253-.089-.3457-.076-.1205.013-.2332.066-.321.149-.0398.05-.0686.108-.0846.17-.0159.061-.0187.126-.008.189s.0346.123.0701.176.0818.098.1358.132c.0451.041.0984.072.1565.091s.1196.025.1802.018c.0607-.007.1192-.027.1715-.058.0523-.032.0973-.074.1318-.125z"/><path d="m97.6728 131.866c-.0431-.035-.0932-.061-.1471-.076-.0538-.015-.1102-.018-.1654-.01-.0553.008-.1082.028-.1555.057-.0472.03-.0876.07-.1186.116-.0456.044-.0811.098-.1041.157s-.033.122-.0292.186c.0038.063.0213.125.0512.181s.0715.104.1221.143c.0489.039.105.067.1651.084s.1229.022.1848.014c.062-.007.1219-.027.1762-.057.0544-.031.1021-.072.1405-.121.0677-.107.093-.236.0708-.36-.0222-.125-.0903-.237-.1908-.314z"/><path d="m96.9602 138.667c-.1415-.108-.32-.155-.4963-.132-.1762.024-.3359.117-.4437.258-.1079.142-.1551.32-.1314.497.0238.176.1166.335.258.443.1836.09.3921.115.5918.072.1997-.044.3788-.154.5082-.312.16-.206.0067-.64-.2866-.826z"/><path d="m95.6728 135.447c.0512.03.1082.05.1673.058.0591.007.1191.003.1765-.013.0573-.017.1108-.044.1571-.082.0464-.037.0846-.084.1124-.136.0392-.051.0672-.11.0822-.172.0149-.062.0164-.127.0045-.19-.012-.063-.0372-.123-.074-.176-.0368-.052-.0843-.096-.1394-.129-.1187-.047-.2498-.051-.3713-.012s-.2258.118-.2953.225c-.0514.109-.0614.232-.0283.347.0331.116.1072.215.2083.28z"/><path d="m93.8669 139.907c-.06.173-.14.606 0 .713s.4933-.187.5-.253c0-.2.08-.46-.1-.607s-.3667.06-.4.147z"/><path d="m95.7195 132.54c.0953-.012.187-.044.2695-.093.0826-.05.1542-.115.2105-.193.0769-.106.1303-.226.1568-.354.0264-.127.0252-.259-.0035-.386 1.3778.16 2.76.278 4.1462.353-.033.113-.053.227-.08.347-.284.05-.5487.177-.7662.366l-.1867-.1c-.1913-.039-.3905-.011-.5633.08-.1727.091-.3083.24-.3833.42-.0171.22.051.437.1901.607s.3384.28.5565.307c.1194.012.2399 0 .3544-.036.1145-.035.2208-.093.3123-.171l.0932.08c.38.3.713.14 1.033-.127.194.18.32.474.667.287.091-.046.164-.122.204-.216s.046-.199.016-.297c-.1-.367-.407-.3-.667-.267-.06-.16-.113-.307-.173-.453l.58-.254c.031-.162.047-.327.047-.493l1.62.04h.066c.067.01.134.01.2 0h1.467c-.01.124.014.248.071.359.056.111.143.204.249.268.095.067.204.114.319.136.114.022.233.02.346-.007.114-.028.22-.079.313-.15.092-.072.168-.163.222-.266.07-.116.102-.251.093-.387l1.147-.053c.135 0 .263-.053.358-.148s.149-.224.149-.359c0-.134-.054-.263-.149-.358s-.223-.148-.358-.148c-12.2998.12-24.5398-2.667-34.9132-9.414-6.2448-4.129-11.6176-9.446-15.8133-15.646-.8222-1.153-1.5618-2.363-2.2133-3.62-.0004-.03-.0073-.059-.0202-.085-.0129-.027-.0314-.05-.0544-.068-.0229-.019-.0496-.032-.0782-.039s-.0584-.007-.0872-.002l-.18-.313c-.2037-.049-.4187-.018-.6.087-.0582.055-.1044.121-.1361.194-.0316.073-.048.153-.048.232 0 .08.0164.159.048.233.0317.073.0779.139.1361.194.0329.055.0776.103.1309.139s.1139.06.1775.07c.0635.01.1286.006.1905-.011.0619-.018.1191-.049.1677-.091l.06.14c-.0543.059-.0887.134-.0983.215-.0095.08.0062.161.045.231.0314.06.0799.109.1393.141.0593.032.1269.046.194.039.4711 1.018.9986 2.008 1.58 2.967-.0116.074-.0063.15.0157.221.0219.072.0599.138.111.192.0571.061.1316.103.2133.12.1334.22.2734.447.4134.667-.1667.267-.1334.507.1533.787.1442.143.337.226.54.233.12.167.2333.347.36.513-.1959.007-.3838.08-.5333.207-.4134.327-.3534 1.013 0 1.127.3533.113.5333-.074.6.053.0666.127.08.447.3333.5s.46-.28.6133-.513c.3334.444.6734.889 1.02 1.333-.0249.084-.0312.172-.0185.258.0127.087.044.169.0919.242.0675.072.1492.129.2398.168s.1882.059.2868.059c.0667.073.1267.153.1934.233.42.5.86 1 1.3333 1.487-.1271.012-.2466.066-.34.153-.0772.131-.1074.284-.0855.435.0218.15.0943.288.2055.392.0544.067.123.121.2008.158.0779.037.163.056.2492.056s.1713-.019.2492-.056c.0778-.037.1464-.091.2008-.158.037-.04.0684-.085.0933-.134.8.84 1.6334 1.647 2.4867 2.434-.0308.064-.0468.135-.0468.206 0 .072.016.143.0468.207.0591.073.1331.133.2169.175.0839.043.1758.067.2698.072.0508-.009.0988-.029.14-.06.5133.453 1.04.893 1.5666 1.333-.0705.148-.107.31-.107.473 0 .164.0365.326.107.474.0717.188.2152.34.399.423.1837.082.3927.088.581.017.1592-.067.2975-.175.4-.314.36.28.7334.554 1.1.82.0557.07.1295.123.2134.154 1.2133.889 2.46 1.704 3.74 2.446-.0267.092-.0267.189 0 .28.0851.173.2298.31.4074.384.1775.075.3761.083.5592.023.0326-.017.0601-.042.08-.073.4934.28.9867.553 1.4867.813-.0086.092.0075.184.0467.267.2133.473.7533.78 1.1133.626.0889-.041.1684-.1.2333-.173.6667.333 1.3734.667 2.0734.96.0522.1.1368.178.2399.223.103.045.2183.053.3267.024.7267.313 1.46.613 2.2.893 0 .253.2734.3.52.26v-.053l.98.353c-.0123.071-.0123.143 0 .213.0213.056.0535.107.0948.15.0412.043.0906.077.1452.101.0547.023.1135.036.173.036.0596.001.1186-.011.1737-.033l.08-.04c.0606.146.1607.272.2891.363.1284.092.28.146.4375.157h.0601c-.0954.123-.1521.272-.1627.427-.0107.156.025.311.1026.446.053.12.125.23.2134.327-.0555.009-.1082.031-.1543.063-.0462.032-.0845.074-.1124.123-.033.073-.051.152-.0531.232-.002.08.0119.16.0411.234.0292.075.0731.143.1289.2.0559.058.1227.103.1965.134.1365.055.286.069.4303.041.1444-.028.2775-.098.383-.201.0784-.104.1208-.232.1208-.363s-.0424-.258-.1208-.363c.1698-.1.3002-.255.3694-.44.0691-.184.0729-.387.0106-.574-.0809-.184-.2187-.339-.3933-.44 1.2733.374 2.56.667 3.8533.974-.016.035-.0243.074-.0245.113-.0001.039.0079.078.0236.114.0157.035.0387.068.0676.094.0288.026.0629.046.1.059.1415.02.2851.02.4266 0 .0399-.077.0626-.161.0667-.247.56.116 1.12.222 1.68.32.0257.038.0572.072.0933.1.0734.071.1714.111.2734.111.1019 0 .1999-.04.2733-.111.3133.053.6266.107.94.147-.0691.105-.123.219-.16.34-.0358-.043-.0808-.077-.1316-.1-.0507-.023-.106-.035-.1617-.034-.0782.009-.1538.034-.2224.073-.0686.038-.1288.09-.1772.152s-.084.133-.1046.209c-.0207.076-.0261.155-.0158.233.0232.145.0878.28.186.388.0982.109.2257.187.3673.225.1309.015.2633-.012.3773-.078.1141-.066.2039-.167.256-.288.1357.062.2841.092.4333.086.013.023.0315.041.0537.054.0222.012.0474.019.073.019s.0508-.007.073-.019c.0223-.013.0408-.031.0537-.054z"/><path d="m102.887 138.153c-.07-.011-.142-.006-.209.016-.068.022-.129.06-.178.111-.073.093-.153.407-.04.507s.413-.047.513-.127c.035-.036.06-.08.075-.128.015-.047.018-.098.009-.147-.008-.049-.028-.096-.058-.136-.029-.04-.068-.073-.112-.096z"/><path d="m100.667 142.246c-.066.11-.086.24-.058.364.029.125.104.233.211.303.052.038.112.065.175.079.063.013.129.013.192-.001s.122-.041.174-.08.095-.088.126-.145c.058-.104.077-.226.051-.343s-.094-.22-.191-.29c-.051-.04-.11-.069-.173-.085s-.128-.019-.192-.008c-.064.01-.125.034-.179.07-.055.035-.101.082-.136.136z"/><path d="m101.747 135.18c-.071-.048-.151-.081-.236-.096-.084-.015-.171-.012-.254.008-.083.021-.161.059-.228.112s-.123.12-.162.196c-.1.138-.147.307-.133.477.013.169.086.329.206.45.166.067.348.083.523.045s.334-.128.457-.259c.059-.069.101-.151.124-.238.023-.088.027-.18.01-.269-.016-.09-.052-.174-.106-.248-.053-.073-.122-.134-.201-.178z"/><path d="m98.2799 140.314c-.0123.031-.0137.066-.0041.099.0097.032.0299.061.0575.081.0333 0 .14 0 .16-.054.02-.053 0-.126 0-.16-.0154-.013-.0337-.023-.0535-.029s-.0407-.007-.061-.004c-.0204.003-.0398.011-.0569.022-.0171.012-.0314.027-.042.045z"/><path d="m46.4134 101.506c.0533-.073.16-.226.06-.353s-.2933 0-.3133.053c-.0336.045-.0517.098-.0517.154 0 .055.0181.109.0517.153.0161.018.0359.032.058.042.0222.009.0461.014.0702.013.024 0 .0477-.006.0693-.017s.0406-.026.0558-.045z"/><path d="m100.667 137.6c-.024-.08-.063-.155-.114-.22-.061-.092-.147-.165-.248-.21-.102-.044-.213-.059-.3228-.042-.1093.017-.2115.064-.2946.137s-.1435.169-.1743.275c-.1095.303-.179.619-.2067.94.0128.188.0839.367.2034.513s.2814.251.4633.3c.2067.04.6667-.033.6667-.46-.347-.62.153-.707.027-1.233z"/><path d="m98.1798 143.026c-.057.046-.1033.104-.1355.17-.0323.065-.0497.137-.0511.21.0666.294.44.314.58.24.0429-.04.0764-.088.0984-.143.0219-.054.0316-.113.0284-.171-.0032-.059-.0192-.116-.0468-.167-.0277-.052-.0664-.097-.1134-.132-.0543-.032-.116-.049-.1791-.051-.0631-.001-.1254.014-.1809.044z"/><path d="m99.4929 135.713c-.1134-.16-.6667-.126-.72.12-.0171.099.0009.2.0509.286.0499.087.1286.153.2224.187.24.047.5666-.433.4467-.593z"/><path d="m83.9999 135.147c-.1867.106-.1467.26-.08.406.0666.147.3133.3.4733.16.0947-.108.1733-.229.2333-.36-.0261-.056-.0635-.106-.1097-.147s-.1003-.072-.159-.091c-.0587-.02-.1207-.027-.1823-.021-.0615.005-.1213.023-.1756.053z"/><path d="m85.3333 138.873c-.038.026-.0676.063-.0853.105-.0178.043-.0229.09-.0147.135.04.08.2066.1.3.087.0933-.013.2-.153.1533-.273s-.2533-.08-.3533-.054z"/><path d="m87.6796 133.554c-.1575-.06-.3284-.073-.4934-.04s-.3174.111-.44.226c-.1666.2-.3466.407-.1266.667s.2866.44.4333.667c.0035.059.0209.117.0508.168.03.052.0716.096.1216.128.0499.033.1068.053.1661.059.0592.007.1191-.001.1748-.022.1234-.04.2264-.126.2873-.241.061-.114.0751-.248.0394-.372-.0364-.165-.0364-.336 0-.5.0453-.131.0492-.272.011-.404-.0382-.133-.1166-.25-.2243-.336z"/><path d="m91.9199 138.246c-.0534.047-.1134.287 0 .36.1133.074.28-.066.3466-.126.0667-.06.0534-.194 0-.24-.053-.031-.1132-.046-.1742-.045-.061.002-.1206.019-.1724.051z"/><path d="m85.4664 131.287c-.0438.021-.0938.024-.1401.01-.0464-.015-.0856-.046-.1099-.088-.0244-.042-.032-.091-.0215-.139.0105-.047.0384-.089.0782-.117.1122-.193.1451-.423.0916-.641-.0534-.217-.189-.405-.3782-.525-.1177-.063-.2472-.101-.3804-.112-.1331-.01-.267.007-.393.052 0 0 0 0 0-.04-.0307-.068-.0754-.129-.1313-.178s-.1216-.086-.1928-.108c-.0712-.023-.1463-.029-.2203-.02s-.1452.034-.2089.072c-.0869.026-.1666.071-.2325.133s-.1162.139-.1467.224c-.0306.085-.0406.177-.0291.266.0114.09.044.176.095.251.0484.112.1226.21.2165.288.0938.077.2046.132.3234.158.0297.169.1228.319.26.42.2085.096.4424.122.6667.074-.0391.114-.0391.239 0 .353.043.103.1237.185.2254.229.1018.045.2168.049.3213.011.0623-.01.1215-.033.173-.07.0515-.036.0937-.084.1235-.14.0297-.056.0461-.117.0479-.18s-.0112-.126-.0378-.183z"/><path d="m103.767 134c.093-.118.14-.267.13-.417s-.075-.291-.184-.396c-.098-.075-.22-.111-.343-.101-.123.009-.238.064-.323.154-.075.144-.098.308-.067.467.032.159.116.302.24.406.088.057.194.076.296.055.103-.021.192-.081.251-.168z"/><path d="m82.6662 136.334c-.08 0-.26.2-.22.3s.2933.12.3666.086c.0499-.025.0903-.067.1154-.117.0252-.05.0338-.107.0246-.163-.0304-.045-.0745-.08-.126-.099-.0515-.02-.1077-.022-.1606-.007z"/><path d="m84.0925 133.333c-.0255-.055-.0621-.105-.1077-.146s-.0992-.072-.1573-.092c-.0582-.019-.1197-.026-.1808-.021s-.1203.023-.1742.053c-.1866.106-.1466.26-.08.406.0128.048.0371.092.0709.128.0339.036.0761.063.1229.079.0469.016.0969.02.1457.012.0487-.008.0947-.028.1339-.059.0951-.106.1717-.228.2266-.36z"/><path d="m90.1063 135.267c-.2531-.086-.5289-.076-.7749.029-.246.104-.4448.296-.5585.538-.1133.36.3334.566.5934.493s.1866 0 .3866.187c.0496.066.1157.118.1918.151.076.033.1594.045.2418.036.0823-.009.1608-.04.2274-.09.0667-.049.1192-.115.1524-.191.0781-.215.0744-.451-.0104-.664s-.2448-.387-.4496-.489z"/><path d="m90.6127 136.593c-.0434.028-.0789.067-.1034.112-.0245.046-.0371.096-.0366.148 0 .067.18.247.2933.2s.0867-.267.0667-.36c-.0189-.04-.0514-.072-.0917-.09-.0403-.019-.0858-.022-.1283-.01z"/><path d="m91.8195 132.574c-.0214-.056-.0535-.107-.0946-.15s-.0903-.078-.1448-.102-.1132-.037-.1728-.039c-.0595-.001-.1188.009-.1744.031-.1059.055-.1906.144-.2412.253-.0506.108-.0642.23-.0388.347.0224.055.0559.105.0982.147.0424.042.0929.075.1484.097.0555.021.1149.032.1745.03.0595-.002.1182-.016.1722-.041.0575-.017.1108-.046.1563-.085.0456-.038.0825-.087.1083-.141s.0399-.113.0414-.172c.0015-.06-.0096-.12-.0327-.175z"/><path d="m87.9461 131.294c-.0667.093.0466.313.1133.353.0215.017.0461.03.0724.037.0264.008.0539.01.0812.007.0272-.003.0536-.011.0776-.025.0239-.013.0451-.031.0621-.052.0171-.022.0298-.046.0374-.073.0075-.026.0098-.054.0067-.081s-.0115-.053-.0248-.077-.0311-.046-.0526-.063c-.06-.053-.32-.113-.3733-.026z"/><path d="m86.9726 136.853c-.2084.173-.3454.418-.3848.686-.0393.268.0217.541.1715.767.2467.28.6667 0 .7267-.253s.0866-.167.36-.233c.0832-.006.1635-.034.2324-.081s.1239-.111.1593-.187.0499-.159.042-.242-.038-.163-.0871-.23c-.1419-.179-.3435-.3-.5676-.341-.2242-.042-.4558-.001-.6524.114z"/><path d="m113.593 141.18c-.015.031-.018.065-.009.098.008.033.028.062.055.082.04 0 .147 0 .16-.054.014-.053.034-.126 0-.16-.015-.013-.032-.023-.052-.029-.019-.006-.039-.007-.059-.004s-.039.011-.055.022c-.017.012-.031.027-.04.045z"/><path d="m117.366 139.433c-.038.051-.066.108-.082.17-.015.062-.018.126-.007.188.011.063.034.123.069.176s.08.098.134.133c.038.04.085.071.138.09.052.019.108.025.164.017.055-.007.108-.027.154-.058.046-.032.084-.074.11-.122.039-.046.068-.1.085-.157.017-.058.022-.118.015-.178-.008-.059-.028-.117-.059-.168-.03-.051-.072-.096-.121-.131-.089-.064-.197-.095-.307-.087-.109.007-.213.052-.293.127z"/><path d="m115.266 133.626c-.065.082-.112.175-.139.276-.027.1-.032.205-.017.308.016.102.053.201.108.288.055.088.128.164.214.222.196.124.43.172.658.135.229-.037.436-.156.582-.335.102-.196.136-.421.096-.638-.039-.217-.151-.415-.316-.562-.096-.064-.205-.106-.319-.125-.113-.019-.23-.014-.342.015s-.216.081-.307.153c-.09.071-.165.161-.218.263z"/><path d="m114.813 141.113c-.053.04-.08.307 0 .353.08.047.28-.06.347-.126.067-.067 0-.227 0-.24-.054-.028-.115-.042-.175-.039-.061.002-.12.02-.172.052z"/><path d="m115.793 136.92c-.093.133 0 .4.247.54.029.028.064.049.102.06.039.012.08.014.119.006.04-.008.077-.025.108-.051.031-.025.055-.058.071-.095.107-.18.153-.373-.047-.533-.093-.059-.203-.083-.313-.07-.109.013-.21.064-.287.143z"/><path d="m113.913 135.567c-.061.079-.089.179-.078.278.012.1.061.191.138.255.133.14.62 0 .667-.153.01-.08.003-.161-.023-.237-.025-.077-.068-.146-.124-.203-.038-.043-.085-.076-.138-.096-.053-.021-.111-.028-.167-.022-.057.005-.111.025-.159.056s-.088.072-.116.122z"/><path d="m113.673 139.08c-.015.054-.014.112.002.167.017.054.048.102.091.139.087.047.267.134.32 0 .023-.051.031-.107.024-.162s-.029-.107-.064-.151c-.046-.02-.32-.093-.373.007z"/><path d="m123.547 130.726c-.067.094.046.314.113.354.021.017.046.029.072.037.027.008.054.01.082.007.027-.003.053-.012.077-.025s.045-.031.062-.053c.017-.021.03-.046.038-.072.007-.026.009-.054.006-.081s-.011-.054-.024-.078c-.014-.024-.032-.045-.053-.062-.06-.053-.307-.087-.373-.027z"/><path d="m113.426 139.887c-.124-.053-.262-.071-.396-.051s-.26.077-.364.164c-.055.107-.068.231-.034.346.033.116.11.214.214.274.052.033.11.054.17.062.061.008.122.004.181-.013.058-.017.112-.046.159-.086.046-.039.084-.088.11-.143.147-.22.14-.44-.04-.553z"/><path d="m104.366 137.473c-.266-.186-.573-.086-.813.267-.044.049-.077.106-.099.168s-.031.128-.027.194c.003.065.02.129.048.189.029.059.069.112.118.156.16.077.338.109.514.091.177-.018.345-.084.486-.191.05-.07.085-.151.1-.235.016-.085.013-.172-.008-.255-.022-.084-.062-.161-.117-.227-.055-.067-.124-.12-.202-.157z"/><path d="m127.293 130.04c-.042-.021-.09-.029-.136-.022-.047.007-.09.029-.124.062-.047.067 0 .22.087.293.086.074.233.094.313 0 .08-.093-.1-.28-.14-.333z"/><path d="m122.426 134.44c-.066.134-.14.514 0 .58.14.067.447-.073.567-.166.12-.094.107-.34-.087-.5-.028-.043-.069-.077-.117-.097-.047-.02-.1-.026-.151-.016-.051.009-.098.033-.135.068-.038.035-.064.081-.077.131z"/><path d="m120.033 129.287c-.107-.065-.233-.088-.356-.066-.122.022-.233.088-.31.186-.056.112-.071.24-.042.362.03.122.101.23.202.304.092.061.206.082.314.06.109-.023.204-.087.266-.18.04-.048.071-.103.09-.163.018-.06.024-.123.017-.186-.007-.062-.026-.123-.057-.177-.032-.055-.074-.103-.124-.14z"/><path d="m121.333 130.533c-.18-.087-.38-.113-.46.087s-.133.56 0 .666c.134.107.474-.18.58-.306.107-.127.114-.314-.12-.447z"/><path d="m120.24 139.14c-.054.107-.094.34 0 .413.066.03.139.042.212.035.072-.007.141-.033.201-.075.073-.066.147-.413.047-.486-.1-.074-.42.04-.46.113z"/><path d="m119.006 130.827c-.173-.053-.359-.045-.527.021-.168.067-.309.188-.399.345-.194.307-.08.607.306.84.125.062.267.081.403.053.137-.028.26-.1.351-.206.07-.172.095-.36.071-.544-.023-.185-.093-.36-.205-.509z"/><path d="m107.406 136.56c-.4-.287-.84-.307-1.02-.047-.066.166-.079.348-.037.521s.137.329.271.446c.148.056.309.066.463.027.154-.038.292-.122.397-.24.07-.109.101-.238.088-.366-.014-.129-.071-.249-.162-.341z"/><path d="m108.353 139.886c.04-.049.069-.105.086-.166s.021-.125.012-.187c-.008-.063-.03-.123-.063-.177s-.077-.1-.128-.136c-.106-.067-.232-.092-.355-.071s-.234.086-.312.184c-.057.111-.072.239-.044.361s.098.23.197.306c.047.033.1.057.157.07.056.012.114.013.171.003.057-.011.111-.033.159-.065s.089-.073.12-.122z"/><path d="m107.846 144.134c-.093.093-.173.626 0 .766.174.14.627-.213.74-.373.047-.098.062-.207.043-.314s-.071-.204-.149-.279c-.167-.134-.48.066-.634.2z"/><path d="m104.946 140.38c-.047-.042-.103-.073-.162-.092-.06-.019-.123-.025-.186-.019-.062.006-.123.025-.178.056-.054.03-.102.071-.141.121-.041.037-.073.083-.093.135-.021.051-.029.107-.023.162.005.055.023.108.052.155.03.047.07.086.118.115.043.048.096.085.155.11.059.024.124.035.188.031s.126-.022.182-.054c.056-.031.104-.075.141-.127.067-.088.099-.197.089-.307s-.06-.212-.142-.286z"/><path d="m108.593 141.14c-.18-.1-.373-.167-.533 0-.049.115-.058.245-.024.366s.108.227.211.3c.146 0 .3-.106.413-.226.031-.031.055-.068.069-.109.014-.042.017-.086.011-.129-.007-.043-.023-.083-.049-.119-.025-.035-.059-.064-.098-.083z"/><path d="m105.48 138.906c-.057.13-.064.276-.019.41.044.134.136.247.259.317.067.054.144.093.227.115.084.022.17.026.255.012s.166-.045.238-.092.133-.109.18-.182c.092-.119.138-.269.128-.42s-.074-.293-.182-.4c-.182-.087-.388-.111-.585-.068-.197.044-.373.152-.501.308z"/><path d="m104.86 142.906c-.07.103-.099.227-.083.35s.076.236.17.317c.107.067.237.09.361.063.124-.026.233-.098.305-.203.058-.111.074-.238.047-.36-.026-.122-.095-.23-.193-.307-.048-.035-.103-.06-.161-.073-.058-.012-.118-.011-.175.002-.058.013-.112.039-.159.076-.047.036-.085.082-.112.135z"/><path d="m110.84 137.546c-.115-.052-.245-.063-.366-.03-.122.033-.229.107-.301.21-.077.129-.109.28-.089.429.019.149.088.287.196.391.15.083.322.115.492.094.17-.022.329-.097.454-.214.107-.193-.086-.673-.386-.88z"/><path d="m111.373 143c-.021.061-.023.127-.005.189s.054.117.105.158c.047.02.098.027.149.019.05-.007.098-.027.137-.059.025-.044.038-.094.038-.144s-.013-.099-.038-.143c-.086-.053-.32-.14-.386-.02z"/><path d="m112.666 132.667c.098-.128.146-.286.137-.447-.01-.16-.078-.312-.19-.426-.227-.26-.507-.24-.787.18-.082.315-.144.636-.187.96-.007.056-.003.113.013.167s.043.105.079.148.082.078.132.103c.051.025.106.039.163.042.111.017.225-.011.316-.077s.153-.166.171-.277c.034-.13.085-.256.153-.373z"/><path d="m113.027 136.873c.113-.093.053-.527-.147-.667s-.293 0-.393.147c-.034.036-.059.08-.071.128-.013.047-.014.097-.003.146.011.048.034.092.067.13.033.037.074.065.12.083.147.073.314.126.427.033z"/><path d="m110.239 134.44c-.046-.533-.153-.72-.446-.747-.099-.014-.201.004-.288.054s-.155.127-.192.22c-.063.108-.084.236-.06.358.025.123.093.233.193.308.313.16.553-.033.793-.193z"/><path d="m109.559 131.587c.117.094.26.15.409.158.15.008.298-.031.424-.111.088-.137.141-.293.154-.454.013-.162-.015-.325-.08-.473-.667.073-1.334.147-2 .2.046.167.16.313.313.3.153-.022.309.003.448.07.14.068.255.176.332.31z"/><path d="m110.3 135.6c-.049-.055-.11-.097-.178-.123-.069-.025-.143-.034-.215-.024-.086.04-.162.096-.225.166s-.111.152-.142.241c-.067.353.26.38.527.5.246-.24.426-.474.233-.76z"/><path d="m92.9794 135.633c-.2133.073-.14.347-.08.42s.4467.433.6067.373.1-.526.04-.566-.3467-.3-.5667-.227z"/><path d="m56.6658 111.58c-.24.1-.1533.667.18.947.0833.074.1913.116.3033.116.1121 0 .2201-.042.3034-.116.2466-.2.38-.614.2333-.807-.28-.387-.76-.247-1.02-.14z"/><path d="m56.5793 115.62c-.1379.119-.2276.285-.2522.465-.0245.181.0177.364.1189.515.1371.12.3104.189.4919.198.1815.008.3605-.045.5081-.151.2-.167.1533-.667-.0733-.933-.045-.06-.1016-.11-.1664-.147-.0647-.037-.1363-.06-.2104-.069s-.1492-.002-.2208.019-.1382.056-.1958.103z"/><path d="m55.8463 114.487c-.1666-.12-.5266 0-.6666.1-.0356.037-.0629.081-.08.129-.0171.049-.0237.1-.0193.151.0045.051.0198.101.0449.146.0252.045.0596.083.101.114.0311.034.0699.06.1133.076.0433.016.0899.022.1357.016.0459-.006.0896-.023.1275-.05.0379-.026.0689-.061.0902-.102.1133-.12.3067-.454.1533-.58z"/><path d="m58.4726 119.6c-.12-.167-.38 0-.4533.053s-.1733.24-.0533.367c.0515.042.1163.066.1833.066s.1318-.024.1833-.066c.08-.02.26-.247.14-.42z"/><path d="m55.9058 122c-.0246.042-.0376.089-.0376.137s.013.095.0376.137c.0457.026.0974.04.1501.04.0526 0 .1043-.014.15-.04.0533-.054.1666-.234 0-.36-.1667-.127-.2067.046-.3001.086z"/><path d="m55.6868 117.253c-.016.057-.016.117 0 .174.0408.015.0858.015.1266 0 0-.054.0867-.127.0467-.18-.04-.054-.1133.006-.1733.006z"/><path d="m60.5529 116.82c-.0457.064-.0776.137-.0937.214-.016.077-.0159.156.0003.233.0534.12.4667.26.6134.12.0785-.079.1227-.185.1227-.297 0-.111-.0442-.218-.1227-.296-.0317-.04-.0725-.072-.1189-.093s-.0972-.03-.148-.028c-.0509.003-.1004.018-.1444.043-.044.026-.0813.061-.1087.104z"/><path d="m61.8929 115.874c.0501-.043.0889-.098.1131-.16.0242-.061.033-.127.0258-.193s-.0303-.129-.0673-.183c-.037-.055-.0867-.1-.1449-.131-.1267-.047-.36.16-.5333.273-.0238.011-.045.027-.0622.047s-.03.044-.0375.069c-.0076.025-.0097.051-.0063.077s.0122.052.026.074c.0824.097.1957.162.3208.185s.2542.003.3658-.058z"/><path d="m58.7595 120.666c-.2.047-.4467.507-.3.667.1466.16.4333.36.6667 0 .2333-.36-.1667-.74-.3667-.667z"/><path d="m61.6331 123.42c-.0866.093-.2666.38-.0666.573.2.194.4266-.033.4933-.113.0327-.063.0498-.132.0498-.203s-.0171-.141-.0498-.204c-.0222-.034-.0517-.063-.0864-.085s-.0738-.036-.1145-.041-.082-.001-.121.011c-.039.013-.0748.034-.1048.062z"/><path d="m61.7993 121.613c.0734 0 .18-.173.1334-.28-.0467-.107-.2067-.133-.2867-.107-.08.027-.2533.2-.2133.3s.2933.12.3666.087z"/><path d="m62.5402 120.5c-.0241.011-.0432.03-.0533.054-.2067 0-.6667.366-.5334.6.0502.097.1338.173.2355.213.1017.041.2146.043.3179.007.1466-.074.1866-.34.1533-.547h.0733c.0534 0 .2067-.16.16-.28-.0466-.12-.2666-.073-.3533-.047z"/><path d="m54.6666 117.267c-.2.146-.0533.513-.08.726l.1267.074c.0899.022.1848.015.2702-.021s.1567-.099.2031-.179c.0942-.1.1508-.23.16-.367-.22-.067-.4933-.373-.68-.233z"/><path d="m60.1401 118c-.1667.06-.16.44 0 .56.1191.054.2493.079.38.073.2533-.22.3-.486.1333-.573-.0772-.044-.1624-.072-.2506-.082-.0881-.01-.1775-.003-.2627.022z"/><path d="m60.0934 115.333c-.16-.667-.7866-.367-.7333-.8s.4267-.2.46-.82-.48-1.013-.86-.8-.3533.607-.4533.627-.5667 0-.58.353c0 .733.6666.587.6666.807s-.1.666-.04.98c.0867.466.42.84.9.666.1114-.019.2175-.061.3115-.124.0939-.063.1737-.144.234-.24.0603-.095.0999-.203.1162-.314.0163-.112.0089-.226-.0217-.335z"/><path d="m48.8661 107.333c-.039.032-.0705.072-.0921.118-.0216.045-.0328.095-.0328.146 0 .05.0112.1.0328.145.0216.046.0531.086.0921.118.0905.12.2215.203.3687.234.1472.03.3004.006.4313-.067.0999-.11.1637-.247.1828-.394.0191-.146-.0075-.295-.0761-.426-.1933-.2-.6133-.134-.9067.126z"/><path d="m55.3861 113.16c.0864-.134.1271-.293.1163-.453s-.0726-.312-.1763-.433c-.0938-.074-.2079-.117-.327-.125-.1192-.007-.2376.023-.3396.085-.3334.306-.4334.733-.22.946.1401.096.3067.146.4765.142.1698-.003.3342-.06.4701-.162z"/><path d="m47.7398 105.647c-.0804.113-.1236.248-.1236.387 0 .138.0432.273.1236.386.0425.046.094.083.1514.108.0573.025.1193.038.1819.038.0627 0 .1246-.013.182-.038s.1089-.062.1514-.108c.0879-.085.1428-.199.155-.321s-.0191-.244-.0884-.345c-.0979-.089-.2199-.146-.3504-.165-.1306-.019-.2638.001-.3829.058z"/><path d="m51.2534 103.26c-.1743-.121-.3812-.186-.5933-.186-.2467-.087-.8.046-.8867.26-.0867.213.1467.373.3733.46.0416.133.1148.254.2134.353.26.253.5733.213.8866-.113.0814-.113.1258-.247.127-.386s-.0409-.274-.1203-.388z"/><path d="m48.0333 112.7c-.0261.037-.0401.082-.0401.127s.014.09.0401.127c.06.066.22 0 .3067 0 .0866 0 .14-.214.06-.314s-.2934.027-.3667.06z"/><path d="m47.6396 99.5865c-.1533-.28-.2333-.6333-.5933-.78l-.4267.1467c-.108-.0743-.2273-.1306-.3533-.1667.0032-.0154.0032-.0313 0-.0467-.0534-.1266-.2667-.0933-.3067-.06l-.0867.0734c-.0613.0146-.117.0471-.16.0933-.06.0746-.0928.1675-.0928.2633s.0328.1888.0928.2634c.2067.3266.36.3666.7934.2266.2524.1436.5128.2727.78.3867.0534.0135.1096.0122.1623-.0041.0527-.0162.0999-.0467.1364-.088.0366-.0414.061-.092.0706-.1463s.0041-.1102-.016-.1616z"/><path d="m80.9267 132.833c-.0787.033-.1498.082-.2088.143-.059.062-.1046.135-.134.215s-.042.166-.0369.251.0277.168.0664.244c.1.274.4467.36.8267.207.0634-.014.1228-.042.1742-.082s.0935-.091.1234-.148c.0298-.058.0467-.122.0494-.187s-.0088-.13-.0337-.19c-.0872-.14-.209-.255-.3539-.335-.1448-.079-.3076-.12-.4728-.118z"/><path d="m49.9599 105.247c-.1334-.147-.28-.12-.4334.04 0 .06-.0333.166 0 .213.1212.178.1798.392.1667.607 0 .1.22.246.3467.26.0773-.003.1532-.022.2231-.055.0699-.034.1323-.081.1835-.139.021-.062.0283-.128.0214-.194s-.0279-.129-.0614-.186c-.1339-.194-.2833-.376-.4466-.546z"/><path d="m53.0733 113.213c-.1267-.166-.32-.073-.46.06-.14.134-.1667.434 0 .514.1666.08.4266.253.5733.04.1467-.214-.0133-.494-.1133-.614z"/><path d="m53.6466 114.246c-.1366-.056-.29-.056-.4266 0-.1667.094-.14.467.0466.567.1119.036.2301.047.3467.033.2133-.233.2133-.513.0333-.6z"/><path d="m54.3264 109.667c-.1737-.153-.3939-.243-.6251-.255-.2311-.013-.4596.054-.6483.188-.3533.28-.32.833.0667 1.287.0631.077.1411.141.2293.188.0882.046.1849.075.2843.084.0995.008.1997-.003.2947-.034.0949-.03.1828-.08.2584-.145.1802-.164.2941-.388.3199-.63s-.0382-.485-.1799-.683z"/><path d="m51.0131 117.487c-.0511.039-.0926.089-.1211.147s-.0434.122-.0434.186c0 .065.0149.128.0434.186s.07.108.1211.147c.034.043.0766.079.125.104.0484.026.1016.041.1562.045.0546.003.1093-.004.1608-.023.0514-.019.0985-.048.138-.086.2067-.206.28-.62.1333-.766-.1123-.059-.239-.085-.3654-.074-.1263.011-.247.057-.3479.134z"/><path d="m51.1599 114c-.0927.092-.1502.214-.1624.344-.0121.131.0217.261.0957.369.1533.167.54.127.8534-.153 0-.087 0-.274-.04-.387-.0781-.12-.1993-.204-.3385-.237-.1392-.032-.2855-.009-.4082.064z"/><path d="m50.9467 111.246c-.0565.071-.0986.151-.124.237-.0253.087-.0335.177-.0239.267.0194.18.1098.346.2512.46.1415.114.3224.167.5031.148.1806-.02.3462-.11.4602-.252.1213-.103.1984-.249.2158-.408.0173-.158-.0265-.317-.1224-.445-.1632-.134-.3676-.208-.5788-.209-.2113-.001-.4165.07-.5812.202z"/><path d="m47.4332 113.673c-.0666.047-.18.28-.1067.36.0734.08.3201 0 .3734-.04.021-.017.0383-.039.0511-.063.0127-.024.0205-.051.023-.078.0024-.027-.0005-.055-.0086-.081s-.0213-.05-.0389-.071c-.0175-.021-.0389-.038-.0632-.051-.0242-.013-.0507-.021-.0779-.023-.0272-.003-.0547 0-.0808.008-.0261.009-.0504.022-.0714.039z"/><path d="m74 131.293c-.0547 0-.1082.016-.1551.044s-.0854.068-.1116.116c-.04.1.1067.227.1934.253.0866.027.2933-.04.3266-.166.0334-.127-.1666-.247-.2533-.247z"/><path d="m76.1132 132.42c-.0666-.194-.5133-.16-.7533.1s.0667.62.2333.666c.1667.047.5867-.58.52-.766z"/><path d="m76.1534 129.62c-.1-.18-.2467-.293-.44-.227-.4988.141-.9459.423-1.2867.814-.0398.071-.069.147-.0867.226-.0275.105-.0247.216.0082.319.0329.104.0945.196.1776.266s.1843.114.2918.129c.1076.014.2171-.002.3158-.047.3071-.105.5986-.25.8666-.433.131-.137.2162-.312.2437-.499.0274-.188-.0041-.379-.0903-.548z"/><path d="m72.8993 133.067c.017.107.071.205.1525.277.0816.072.1856.113.2942.116.2733-.04.4333-.667.2733-.773-.16-.107-.7466.113-.72.38z"/><path d="m71.8465 135.1c-.1067.06-.3733.273-.2467.52.1267.247.4134.107.5067.053.0551-.044.0986-.102.1264-.167.0279-.065.0395-.136.0336-.206-.0081-.045-.0268-.087-.0544-.123s-.0634-.065-.1044-.085c-.041-.019-.0861-.029-.1315-.027-.0454.001-.0899.013-.1297.035z"/><path d="m73.333 129.22c.0985-.051.1737-.137.2097-.241.0361-.105.0302-.219-.0163-.319-.0172-.048-.0453-.091-.082-.126-.0368-.035-.0812-.061-.1297-.076s-.0998-.019-.1499-.01c-.0501.008-.0975.028-.1385.058-.0628.047-.1154.105-.1544.173s-.0636.143-.0722.221c-.02.133.32.433.5333.32z"/><path d="m72.2268 129.486c-.18 0-.3.367-.1733.54.1267.174.26.154.3333.194.3134-.127.4467-.36.32-.5-.125-.136-.2963-.219-.48-.234z"/><path d="m75.7396 138.373c-.0183.058-.0219.12-.0102.179.0116.059.0381.115.0769.161.0636.038.1361.058.21.058s.1464-.02.21-.058c.0867-.106-.0733-.386-.1533-.426-.0584-.015-.1196-.015-.178 0-.0583.015-.1118.045-.1554.086z"/><path d="m79.5059 134.627c.1092-.026.204-.093.2639-.188s.0801-.209.0561-.319c-.0405-.144-.1364-.266-.2667-.34-.24-.113-.4133 0-.6666.447.1466.213.2466.513.6133.4z"/><path d="m72.1465 127.72c.1129.016.2278.008.3373-.023.1095-.032.211-.087.2979-.16.087-.074.1573-.165.2064-.268s.0758-.215.0784-.329c.06-.667-.6266-.607-.4333-1s.4733-.047.7067-.62c.2333-.573-.12-1.113-.5534-1.04-.4333.073-.5266.453-.6266.447-.1-.007-.54-.187-.6667.14-.2667.666.4067.76.3933.986-.0133.227-.3133.587-.3666.92-.0734.467.1266.927.6266.947z"/><path d="m79.6394 138.38c-.1885.111-.3338.283-.4124.488-.0787.204-.086.429-.0209.638.0835.206.2421.373.4437.467s.4313.108.6429.04c.1894-.148.3353-.345.4221-.569s.1115-.467.0713-.704c-.1099-.195-.2894-.34-.5023-.407-.2129-.066-.4432-.05-.6444.047z"/><path d="m79.3329 132.474c.1031-.059.1814-.153.2201-.265.0388-.112.0353-.234-.0097-.344-.045-.109-.1284-.199-.2347-.251-.1062-.053-.228-.065-.3424-.034-.1105.046-.1998.132-.2504.241-.0506.108-.0587.232-.0229.346.0493.122.1422.221.2606.277.1184.057.2537.068.3794.03z"/><path d="m78.0794 130.793c-.0758-.014-.1538-.009-.2272.015-.0733.023-.1397.064-.1931.12-.0535.055-.0924.123-.1133.197s-.0231.153-.0064.228c.0303.089.0802.169.146.236s.1457.119.234.151c.0845.008.1698-.003.2488-.035.0789-.031.1491-.081.2045-.145.0385-.069.0614-.145.0672-.223.0057-.078-.0058-.156-.0338-.23-.028-.073-.0718-.139-.1283-.193-.0564-.055-.1242-.096-.1984-.121z"/><path d="m76.0395 134.98c-.0861.029-.1653.076-.2329.136-.0676.061-.1221.134-.1601.217-.0381.082-.059.172-.0613.262-.0024.091.0138.181.0476.265.1067.216.2837.389.5018.491s.4643.126.6982.069c.1708-.091.3012-.243.3654-.425.0642-.183.0575-.383-.0187-.561-.042-.107-.1055-.205-.1867-.286-.0811-.082-.1781-.146-.285-.188-.1069-.043-.2213-.063-.3363-.059-.115.003-.228.03-.332.079z"/><path d="m63.7734 126c-.0552.024-.1049.058-.1457.102-.0407.044-.0716.096-.0905.153-.019.057-.0255.117-.0193.177.0063.059.0252.117.0555.168.0173.052.0455.1.0827.14.0371.041.0824.073.1329.094.0504.021.1048.032.1596.031.0548-.002.1087-.014.1581-.038.2667-.12.4734-.493.38-.667-.0926-.089-.2082-.152-.3341-.18s-.2571-.021-.3792.02z"/><path d="m66.8995 131.9c-.0321.036-.0549.079-.0665.125-.0116.047-.0117.095-.0002.142.0347.041.0793.072.1294.091.0502.018.1044.024.1573.016.0666 0 .2333-.167.14-.334-.0934-.166-.28-.08-.36-.04z"/><path d="m67.2792 126.993c-.2334.073-.22.46-.3134.667.0331.036.0643.074.0934.113.0744.055.1643.084.2566.084.0924 0 .1823-.029.2567-.084.1218-.065.2163-.171.2667-.3-.16-.14-.32-.553-.56-.48z"/><path d="m66.9465 125.366c.0467-.126-.1933-.333-.2866-.346-.0608.006-.1187.029-.1678.065-.0492.036-.0879.085-.1122.141.0026.061.0205.121.0519.173s.0755.096.1281.127c.0729.011.1474.002.2155-.026s.1272-.075.1711-.134z"/><path d="m64.7734 119.48c-.1267-.153-.54 0-.6667.347s.2667.567.4467.573c.18.007.3466-.76.22-.92z"/><path d="m66.5994 123.134c.12.113.28.246.44.133s.14-.467.0867-.62c-.0096-.035-.0291-.067-.0563-.092-.0271-.025-.0608-.042-.097-.048.0019-.194-.0695-.382-.2-.527-.0749-.087-.1669-.158-.2704-.209-.1036-.05-.2163-.079-.3314-.084-.115-.005-.2298.014-.3373.055-.1075.042-.2054.104-.2876.185-.1295.104-.2157.252-.2416.416s.0104.332.1016.471c-.0514-.032-.1087-.052-.1684-.06s-.1204-.004-.1783.013c-.1215.057-.2187.155-.2737.278-.0551.122-.0644.26-.0263.389.0934.2.4667.293.86.133 0-.087.12-.253.0867-.38-.014-.05-.0366-.097-.0667-.14.1443.083.3049.134.4706.149.1657.014.3326-.007.4894-.062z"/><path d="m64.0797 117.88c.0836-.173.1106-.367.077-.557-.0335-.189-.1257-.362-.2636-.496-.0579-.073-.1414-.12-.2333-.132-.0919-.013-.1851.011-.2601.065-.429.293-.7591.709-.9466 1.194-.013.079-.013.16 0 .24.0062.11.044.217.1089.307s.1542.159.2573.2c.1031.04.2157.051.3245.029.1088-.021.2092-.073.2893-.15.2437-.205.461-.44.6466-.7z"/><path d="m69.6459 126.067c-.1675.07-.3044.198-.3862.361-.0818.162-.1032.349-.0604.525.0892.158.2295.282.3979.349.1684.068.3548.077.5287.025.24-.094.3667-.58.24-.907-.0232-.071-.0605-.137-.1095-.193-.049-.057-.1088-.103-.1759-.136-.0671-.032-.1402-.052-.2148-.056s-.1493.007-.2198.032z"/><path d="m70.0529 131.526c-.2067 0-.5867.334-.5134.56.0734.227.2934.487.6667.254.3733-.234.0533-.787-.1533-.814z"/><path d="m68.1934 127.474s.1066.08.12.066c.0133-.013.1199-.093.1-.153-.02-.06-.0934-.047-.1467-.067-.034.046-.0589.098-.0733.154z"/><path d="m69.8466 130.82c.0933 0 .3333-.167.28-.367-.0534-.2-.36-.14-.4467-.1s-.24.167-.1733.327c.0302.06.0813.108.1438.133.0625.026.1321.028.1962.007z"/><path d="m71.2662 123.913c-.1679-.146-.3852-.224-.6079-.216-.2227.007-.4343.099-.5921.256-.1415.167-.2225.378-.2297.597-.0073.219.0596.434.1897.61.1468.167.3516.272.573.295.2215.022.4431-.041.6203-.175.1362-.2.2129-.435.2211-.677.0083-.242-.0522-.481-.1744-.69z"/><path d="m69.3329 123.333c.127-.098.2185-.236.2606-.391s.0325-.319-.0273-.469c-.0628-.103-.1566-.183-.2679-.23s-.2345-.057-.3521-.03c-.4133.18-.6667.547-.5133.827.1006.137.2415.238.4029.291s.3352.053.4971.002z"/><path d="m69.3327 124.76c-.1133-.173-.4933-.167-.6333-.113-.0458.022-.0862.054-.1183.094-.0322.039-.0554.085-.0681.135-.0126.049-.0145.101-.0053.151.0091.05.029.098.0583.14.0178.042.0456.08.0811.11.0355.029.0776.05.1228.06s.0921.008.1367-.004.0856-.035.1194-.066c.1134-.087.4134-.334.3067-.507z"/><path d="m174.579 74.8867h-8.253v8.2534h8.253z"/><path d="m193.666 93.9736h-8.253v8.2534h8.253z"/><path d="m174.579 93.9736h-8.253v8.2534h8.253z"/><path d="m155.499 74.8867h-8.253v8.2534h8.253z"/><path d="m155.499 93.9736h-8.253v8.2534h8.253z"/><path d="m134.393 74.8867h-8.253v8.2534h8.253z"/></g><path d="m105.439 32.8135c-8.4385 0-16.6877 2.5023-23.7042 7.1906s-12.4852 11.3519-15.7145 19.1482c-3.2294 7.7963-4.0743 16.3752-2.428 24.6517s5.7099 15.879 11.677 21.846c5.967 5.967 13.5695 10.031 21.846 11.677 8.2767 1.646 16.8557.801 24.6517-2.428s14.46-8.698 19.148-15.7145 7.191-15.2657 7.191-23.7044c0-11.3159-4.495-22.1683-12.497-30.1698-8.002-8.0016-18.854-12.4968-30.17-12.4968zm0 69.3935c-5.291 0-10.4642-1.569-14.8639-4.5094-4.3997-2.94-7.8287-7.1187-9.8534-12.0076-2.0248-4.8889-2.5542-10.2685-1.5213-15.4583 1.0328-5.1898 3.5815-9.9568 7.3236-13.698 3.7422-3.7413 8.5098-6.2888 13.7-7.3203 5.19-1.0315 10.569-.5008 15.458 1.5251 4.888 2.0259 9.066 5.456 12.005 9.8565 2.939 4.4004 4.507 9.5735 4.505 14.8651 0 3.513-.692 6.9916-2.036 10.2371-1.345 3.2455-3.315 6.1943-5.8 8.678-2.484 2.4838-5.433 4.4538-8.679 5.7978s-6.725 2.035-10.238 2.034z" fill="#1ba9f5"/><path d="m133.935 93.1051-10.465 10.4649 27.143 27.144 10.465-10.465z" fill="#0a89db"/><path d="m73.0864 74.8332c.0636.0134.1293.0139.193.0012.0638-.0127.1243-.0382.178-.075.0536-.0368.0992-.0841.1339-.139.0348-.0549.0581-.1164.0684-.1806.0109-.1304-.0228-.2607-.0954-.3697-.0726-.1089-.18-.1901-.3046-.2303-.1378.0177-.268.0732-.3762.1602-.1082.0871-.1903.2024-.2371.3332-.0267.16.24.46.44.5z" fill="#0d90e0"/><path d="m83.3734 59.4865c.1466.0533.2333-.18.2266-.2267-.0066-.0466-.0333-.22-.1733-.2466-.0225-.0072-.0464-.0092-.0697-.0056-.0234.0035-.0456.0124-.065.026-.0194.0135-.0353.0314-.0467.0521-.0113.0208-.0177.0439-.0186.0675-.0067.0933-.0067.28.1467.3333z" fill="#0d90e0"/><path d="m84.3733 58.993c.1533-.2.3067-.4066.4733-.6-.0408-.0455-.0908-.0818-.1466-.1066-.28-.0867-.6667.0866-.6667.32-.0333.12.1467.2733.34.3866z" fill="#0d90e0"/><path d="m69.1263 76.9668c.0618.0065.1243.0005.1838-.0176s.1148-.0479.1626-.0876c.0479-.0398.0873-.0887.1159-.1439.0287-.0552.046-.1155.051-.1775.0533-.28-.14-.6667-.38-.6667-.1454-.0263-.2954.0039-.4193.0845-.1239.0805-.2124.2053-.2474.3488-.0168.0796-.0157.1618.0032.2409.019.079.0553.1528.1064.2161.0511.0632.1157.1142.189.1494.0733.0351.1535.0534.2348.0536z" fill="#0d90e0"/><path d="m69.4525 68.9864.22-.2266h-.7067c.1434.1107.3096.1882.4867.2266z" fill="#0d90e0"/><path d="m65.3335 70.3871c.0622.0203.1282.0261.1929.0168.0647-.0092.1265-.0332.1805-.0701.1466-.1157.2535-.2741.3061-.4532.0526-.1792.0481-.3703-.0128-.5468-.0255-.1082-.0917-.2024-.1847-.2632-.0931-.0608-.206-.0835-.3153-.0635-.1643.0143-.3192.083-.44.1954-.1207.1123-.2005.2618-.2267.4246-.0033.06-.0033.1201 0 .18v.04c.15.1948.3174.3755.5.54z" fill="#0d90e0"/><path d="m67.186 68.7598h-.4067.0533c.115.0363.2384.0363.3534 0z" fill="#0d90e0"/><path d="m63.5527 77.1999c.0099.1677.0577.331.1396.4776.082.1466.1961.2728.3338.3691.28.08.6133-.2.7066-.6.0934-.4-.06-.5734-.5-.6667-.0722-.0178-.1473-.021-.2208-.0094-.0735.0115-.144.0376-.2073.0767s-.1182.0905-.1614.151c-.0433.0606-.074.1291-.0905.2017z" fill="#0d90e0"/><path d="m68.8467 73.7733c.1219-.1799.1763-.3972.1533-.6133-.08-.2067-.3333-.34-.5333-.5334.1066-.1866.2333-.42 0-.6666-.62 0-.6667.1266-.3867.6666-.1206.224-.1996.4679-.2333.72.0432.2332.1573.4474.3267.6134.1733.2133.4933.12.6733-.1867z" fill="#0d90e0"/><path d="m64.3734 72.4263c.1515.1886.3536.3302.5827.4081.229.078.4756.089.7107.0319.3266-.1467.2533-.5867.34-.6667.0866-.08.5933.2334.9866 0 .179-.1336.3117-.3198.3798-.5325.0682-.2127.0682-.4414.0002-.6541-.0824-.2574-.2617-.4726-.5-.6-.1574-.0702-.3323-.0919-.5021-.0622s-.3269.1094-.4512.2288c-.2067.26-.2133.7667-.3333.82-.12.0534-.6-.2866-.98-.0533-.0885.0536-.1652.1246-.2253.2088-.06.0842-.1023.1798-.1241.2809-.0219.1011-.0228.2056-.0029.3071.02.1015.0604.1979.1189.2832z" fill="#0d90e0"/><path d="m70.046 75.5604c.0843-.0022.1672-.0224.243-.0592.0759-.0368.1431-.0893.197-.1541.1934-.34-.0466-.5533-.32-.7533-.26.1066-.5533.2066-.5466.5466-.0019.0563.0078.1123.0287.1646.0208.0522.0523.0996.0924.1391.0401.0394.088.0702.1406.0902s.1087.0289.1649.0261z" fill="#0d90e0"/><path d="m77.246 56.2935c.2733.0734.5933-.22.6666-.6133.0139-.0479.0173-.0982.01-.1475-.0074-.0493-.0253-.0964-.0525-.1382-.0272-.0417-.0631-.0772-.1052-.1038s-.0895-.0438-.1389-.0505c-.32-.0933-.7067.0733-.76.3133-.02.1474.0057.2974.0736.4298.068.1323.1749.2406.3064.3102z" fill="#0d90e0"/><path d="m75.2391 58.6202c-.08.16.1.5467.3067.6667.0451.0212.0942.0322.144.0324.0498.0001.099-.0107.1441-.0317.0452-.021.0852-.0516.1172-.0898.032-.0381.0552-.0828.068-.1309.14-.3467.1267-.62-.04-.7067-.1353-.0266-.2753-.0165-.4054.0292s-.2456.1254-.3346.2308z" fill="#0d90e0"/><path d="m76.6063 61.3331h.52c.1724-.0978.3011-.2575.36-.4467.0049-.1202-.0305-.2386-.1005-.3365-.0701-.0978-.1708-.1694-.2862-.2035-.46-.0733-.8.0667-.8467.3533.0021.1266.0354.2506.0971.3611.0616.1105.1497.2041.2563.2723z" fill="#0d90e0"/><path d="m80.7929 56.1531c.0521.0163.1069.0219.1612.0166.0543-.0054.107-.0216.1548-.0477.0479-.0261.0901-.0616.124-.1044.0339-.0427.0588-.0919.0733-.1445.0106-.0487.0112-.099.0017-.1479-.0095-.049-.0289-.0954-.057-.1366s-.0642-.0762-.1063-.103c-.0421-.0267-.0892-.0446-.1384-.0525-.0433-.013-.0888-.0171-.1338-.012s-.0884.0193-.1277.0418c-.0393.0224-.0737.0526-.1009.0888-.0272.0361-.0468.0775-.0576.1214-.0203.0448-.031.0934-.0315.1426s.0093.0979.0288.1431c.0194.0452.0481.0858.0842.1193.036.0334.0787.059.1252.075z" fill="#0d90e0"/><path d="m80.0793 57.3334c-.3333-.0734-.5667.1066-.6667.52-.1.4133.12.5666.5267.6666.1142.0375.2384.0301.3474-.0207.1089-.0508.1945-.1411.2393-.2526.0169-.1786-.0158-.3584-.0946-.5196-.0789-.1612-.2007-.2975-.3521-.3937z" fill="#0d90e0"/><path d="m80.2996 53.3797c.1133-.0733.0866-.3866.1-.5933.0047-.0255.0041-.0518-.0019-.0771-.006-.0252-.0172-.049-.0329-.0697s-.0355-.0379-.0582-.0505-.0478-.0203-.0737-.0227c-.1275.0049-.2493.0542-.3443.1394-.095.0853-.1571.201-.1757.3273.003.0676.0227.1334.0576.1914.0348.058.0835.1065.1418.1409.0582.0344.1242.0538.1918.0563s.1348-.0119.1955-.042z" fill="#0d90e0"/><path d="m79.1662 56.8003c.1324.0166.2667-.007.3855-.0676.1189-.0606.2168-.1555.2811-.2724.0081-.1445-.0312-.2878-.1118-.408-.0805-.1203-.1981-.2111-.3348-.2586-.1201-.0152-.2414.0176-.3375.0912s-.1593.1822-.1759.3021c-.0328.1204-.0213.2486.0326.3613.0538.1126.1464.202.2608.252z" fill="#0d90e0"/><path d="m77.4933 57.1604c-.0614.0449-.113.1017-.152.167-.0389.0653-.0643.1377-.0747.213 0 .0934.14.2467.2467.2934.2158.0863.4389.1532.6666.2.2.0466.3067-.0534.34-.2734-.0333-.0466-.06-.1533-.12-.1733-.2016-.0804-.3705-.2259-.48-.4133-.0649-.036-.1375-.056-.2117-.0583-.0742-.0024-.1479.0131-.2149.0449z" fill="#0d90e0"/><path d="m73.3328 60.1803c-.0467-.28-.1134-.5533-.4534-.5133s-.3733.3-.34.5667c.0514.0567.1149.101.1858.1297.071.0287.1475.0409.2238.0357.0764-.0051.1505-.0274.217-.0653s.1235-.0904.1668-.1535z" fill="#0d90e0"/><path d="m76.9463 75.1269c.1173.0247.2395.0075.3454-.0485.1059-.0561.1889-.1474.2346-.2581.0128-.1305-.011-.262-.0687-.3797-.0577-.1178-.147-.2171-.258-.287-.1281-.0081-.2557.0233-.3654.0901s-.1962.1657-.2479.2832c-.02.0631-.0264.1297-.0187.1954.0077.0658.0293.1291.0634.1858.034.0568.0797.1056.1341.1434.0544.0377.1161.0634.1812.0754z" fill="#0d90e0"/><path d="m77.3991 76.8667c.1758.041.3605.0187.5215-.063.1609-.0817.2879-.2176.3585-.3837.06-.2334-.3866-.7334-.6666-.7867-.1547-.0061-.3062.0452-.4253.1441s-.1973.2384-.2197.3916c-.0223.1532.0128.3092.0986.438.0859.1288.2164.2213.3664.2597z" fill="#0d90e0"/><path d="m74.5999 69.1536v.12c-.0733.1866-.1867.26-.36.12-.06-.0534-.1-.1334-.1667-.18-.1337-.0909-.2916-.1395-.4533-.1395s-.3196.0486-.4533.1395c-.143.0768-.2531.2032-.3095.3554-.0565.1522-.0555.3197.0028.4712.0482.1932.1452.3707.2818.5154.1366.1448.3082.252.4982.3113-.0549.1805-.0929.3658-.1133.5533 0 .5467.36.76.84.4867.0942-.0398.177-.1025.2408-.1825.0638-.0799.1066-.1746.1245-.2753s.0103-.2043-.0221-.3013-.0885-.1844-.1632-.2542c-.132-.1312-.2723-.2536-.42-.3667.1866-.1133.3066-.2533.4266-.2533.0812.0065.1628-.004.2395-.0311.0768-.027.1471-.0699.2062-.1259.0591-.0559.1057-.1237.1369-.1989s.0462-.1561.0441-.2375c.0274-.0961.0678-.1881.12-.2733.14-.3067.1067-.54-.0933-.6667h-.32c-.0746.0444-.1388.1043-.1882.1756-.0495.0713-.0831.1524-.0985.2378z" fill="#0d90e0"/><path d="m72.2794 72.4402c-.34 0-.7133-.1067-.9866.26-.0651.1-.1527.1834-.2557.2435-.103.0602-.2186.0955-.3377.1032-.1614.0266-.3091.1069-.4192.2279-.11.121-.1761.2756-.1874.4387.0895.2225.223.4245.3926.5941.1695.1695.3716.3031.594.3926.34-.1533.7934-.34 1.24-.5467.0534 0 .08-.1866.0667-.2733-.0186-.1171-.0128-.2367.017-.3514.0298-.1148.0831-.2221.1563-.3153.2-.2933.0267-.76-.28-.7733z" fill="#0d90e0"/><path d="m74.5134 72.86c-.2934-.0666-.5734.2267-.6667.6667-.0009.1289.0403.2547.1173.3581.077.1035.1856.179.3094.2152.1486.0016.2951-.0358.4249-.1083s.2384-.1776.3151-.305c.0128-.1727-.0284-.3451-.118-.4932s-.2232-.2647-.382-.3335z" fill="#0d90e0"/><path d="m77.9997 71.593c-.206-.0599-.4272-.0388-.6181.0592-.1909.0979-.3371.2652-.4086.4675-.0057.2394.0532.476.1705.6848.1174.2088.2887.3822.4962.5018.2127.0283.4283-.0246.6037-.1482.1754-.1235.2979-.3086.343-.5184.0308-.2147-.0099-.4336-.116-.6228-.106-.1892-.2715-.3382-.4707-.4239z" fill="#0d90e0"/><path d="m75.8197 78.5868c-.32-.0667-.5466.1333-.6666.56s.0533.6267.3133.6667c.1756-.0046.3478-.049.5037-.1298.1559-.0809.2914-.1961.3963-.3369.027-.1731-.0152-.3499-.1175-.4921s-.2565-.2385-.4292-.2679z" fill="#0d90e0"/><path d="m78.7058 74.9466c0-.4444.02-.8889.06-1.3333-.1687.1362-.2843.3273-.3267.54-.0287.144-.0193.293.0275.4322s.1293.2637.2392.3611z" fill="#0d90e0"/><path d="m78.8729 78.5339c0-.2134-.0467-.4334-.0667-.6667-.06.0867-.1067.1867-.1467.2466-.04.06.0067.3401.2134.4201z" fill="#0d90e0"/><path d="m68.1792 75.9999c-.0194-.0917-.058-.1783-.1131-.2541-.0551-.0759-.1256-.1392-.2069-.1859-.3734-.1467-.4867.2067-.7.4533.1933.32.3666.5667.7333.4467.0497-.0101.0968-.0303.1385-.0592.0416-.029.0769-.0661.1038-.1092.0268-.043.0446-.0911.0522-.1412.0076-.0502.005-.1013-.0078-.1504z" fill="#0d90e0"/><path d="m76.9727 69.6337c-.0639-.1859-.193-.3423-.3634-.4403-.1703-.0981-.3704-.1311-.5633-.093-.1773.0951-.315.2503-.3883.4378s-.0774.3949-.0116.5851c.0659.1902.1973.3508.3707.4528.1735.1021.3777.1391.5759.1043.3267-.0667.4533-.5867.38-1.0467z" fill="#0d90e0"/><path d="m65.0327 76.26c-.06.22.0933.58.2933.5933.3141.071.6432.0261.9267-.1267.4-.2866.4267-.3266.1933-.84-.24 0-.4333-.06-.6266-.1-.1656-.0237-.3343.0105-.4776.0967s-.2525.2193-.3091.3767z" fill="#0d90e0"/><path d="m79.1602 59.8931c.04-.3-.2934-.6667-.6667-.7267s-.5933.1467-.6667.5933c-.0099.1414.0255.2822.101.4021.0755.1198.1873.2125.319.2646.2667.0333.88-.3.9134-.5333z" fill="#0d90e0"/><path d="m74.4527 57.1998c.1017.032.2092.0416.315.0282s.2074-.0495.298-.1059c.0905-.0563.1678-.1316.2265-.2206.0588-.089.0976-.1896.1138-.2951.0445-.1871.0189-.3841-.0721-.5536-.0909-.1695-.2407-.2998-.4212-.3664-.3533-.1133-.8267.26-.9467.7467-.0237.0825-.0297.1691-.0174.254.0122.085.0423.1664.0883.2388.046.0725.1068.1344.1785.1816s.1526.0787.2373.0923z" fill="#0d90e0"/><path d="m74.9 60.967c.0013-.0497-.0081-.099-.0275-.1447-.0195-.0457-.0485-.0867-.0852-.1202-.0366-.0335-.0801-.0588-.1274-.074-.0472-.0153-.0972-.0202-.1466-.0144-.3733 0-.6266.12-.6666.3066.0123.0807.0404.1582.0828.228.0423.0697.098.1305.1638.1787h.5934c.1079-.094.1827-.2202.2133-.36z" fill="#0d90e0"/><path d="m72.466 69.2198c.0552-.0625.0972-.1354.1235-.2144.0264-.0791.0365-.1626.0299-.2456h-2.58c.0933.3733 0 .52-.42.3933-.1745-.0606-.365-.0562-.5364.0124-.1715.0685-.3125.1967-.397.3609-.1054.1366-.1626.3042-.1626.4767s.0572.3401.1626.4767c.0424.076.1.1424.1694.1949s.149.0899.2336.11c.0847.0201.1726.0223.2582.0065.0855-.0157.1669-.0492.2388-.0981.2292-.1347.4251-.3193.5734-.54.1266-.1733.2333-.3.4533-.2867.32 0 .4267-.1733.4267-.4666.1006.0929.2204.1626.3508.2044.1305.0418.2685.0546.4044.0374.1359-.0171.2664-.0638.3824-.1366.116-.0729.2146-.1702.289-.2852z" fill="#0d90e0"/><path d="m70.5463 71.8337c.0526.1091.1404.1974.2492.2506s.2324.0682.3508.0428c.1117-.0023.219-.0446.3021-.1192.0832-.0747.1369-.1767.1512-.2875.04-.34-.4533-.8733-.6666-.84-.1459.108-.2592.2541-.3275.4223-.0682.1682-.0887.3519-.0592.531z" fill="#0d90e0"/><path d="m69.5935 71.7863c-.1275-.0234-.2592-.0007-.3716.064-.1123.0647-.198.1673-.2417.2894-.0339.1459-.0204.2988.0383.4366s.1597.2534.2883.33c.1755.0442.3608.0265.5247-.05s.2965-.2072.3753-.37c.06-.2666-.2066-.56-.6133-.7z" fill="#0d90e0"/><path d="m72.2127 58.5135c0-.2534-.12-.3734-.34-.42-.0587-.0158-.1201-.0195-.1802-.0108-.0602.0088-.118.0297-.1698.0615-.0518.0319-.0965.0739-.1315.1237-.035.0497-.0594.1061-.0719.1656-.0094.0542-.0074.1098.006.1632.0134.0535.0378.1035.0718.1468.0339.0434.0766.0791.1252.105.0486.0258.1021.0411.157.045.1208.0176.2436-.0121.343-.0829s.1676-.1773.1904-.2971z" fill="#0d90e0"/><path d="m67.7533 76.9133c-.32-.0867-.54.2267-.6.4067s.2866.5333.6666.4333c.0689-.0283.1304-.0717.18-.1272.0497-.0554.0862-.1213.1067-.1928.0125-.0575.0133-.1169.0021-.1746-.0112-.0578-.034-.1127-.067-.1613-.0331-.0487-.0757-.0901-.1253-.1217-.0496-.0317-.1051-.0529-.1631-.0624z" fill="#0d90e0"/><path d="m67.3334 74.3735c-.4467-1.1467-1.12-1.2467-1.88-.3333-.0796-.0184-.1576-.0429-.2333-.0734-.115-.0615-.2497-.0751-.3746-.0376-.125.0375-.23.1229-.2921.2376-.0729.1034-.112.2269-.112.3534s.0391.2499.112.3533c.3133.6667.6133.7667 1.1867.4667.4333-.22.88-.4267 1.3333-.6667.5333.84.6667.8667 1.1867.32-.0135-.1048-.0493-.2054-.1049-.2952s-.1298-.1666-.2176-.2254c-.0878-.0587-.1871-.098-.2913-.1151-.1042-.0172-.2109-.0118-.3129.0157z" fill="#0d90e0"/><path d="m71.2198 60.7134c-.0194.0515-.0275.1066-.0235.1615.0039.055.0198.1084.0464.1565.0266.0482.0634.0901.1078.1226.0444.0326.0954.0551.1493.0661.115.0382.2403.0295.3489-.0242.1086-.0536.1917-.148.2311-.2625.0116-.0583.0114-.1183-.0005-.1765s-.0354-.1134-.0689-.1624c-.0335-.0491-.0765-.0909-.1264-.1232-.0499-.0322-.1057-.0542-.1642-.0646-.053-.0138-.1082-.0168-.1624-.0087-.0542.008-.1062.0269-.1529.0556-.0467.0286-.0871.0664-.1189.111-.0317.0447-.0541.0953-.0658.1488z" fill="#0d90e0"/><path d="m80.1133 61.3335h.76c-.0404-.0731-.1072-.128-.1867-.1534-.0662-.0206-.1363-.0253-.2046-.0138-.0684.0116-.133.0392-.1887.0805-.0533.0267-.12.0467-.18.0867z" fill="#0d90e0"/><path d="m67.526 85.4937c-.1686-.0366-.3447-.0142-.4989.0635-.1541.0776-.2768.2058-.3477.3632-.0339.1281-.0175.2643.0457.3808.0631.1164.1684.2044.2943.2459.1573.0273.3192-.0028.4562-.0848.137-.0819.2401-.2104.2904-.3619.0252-.0555.039-.1154.0405-.1763.0015-.0608-.0093-.1214-.0317-.178s-.0559-.1082-.0987-.1515c-.0427-.0434-.0938-.0777-.1501-.1009z" fill="#0d90e0"/><path d="m71.2595 82.9801c.18.0159.3603-.0277.5132-.124s.2701-.2401.3335-.4093c.0667-.26-.2133-.6067-.5467-.6667-.4333-.0933-.6666 0-.7666.3467-.0304.1741-.0008.3533.084.5083s.2197.2767.3826.345z" fill="#0d90e0"/><path d="m71.4735 84.1397c0-.1467-.0533-.5267-.2267-.5533-.1733-.0267-.3533.3333-.38.4666-.0266.1334.16.26.28.3067s.3467-.0133.3267-.22z" fill="#0d90e0"/><path d="m72.5133 86.3537c-.1667 0-.5867.0733-.6134.24-.0266.1667.3134.3933.46.4733.1467.08.3934-.1133.42-.3266.0267-.2134-.0533-.3934-.2666-.3867z" fill="#0d90e0"/><path d="m71.5331 90.8738c-.0842.033-.1525.097-.191.1789-.0384.0818-.044.1753-.0156.2611.0866.3401.3533.3801.6666.3934.1467-.2667.3601-.48.1334-.74-.0728-.0818-.1718-.1358-.28-.1529-.1082-.017-.2189.0041-.3134.0595z" fill="#0d90e0"/><path d="m69.3929 83.3332c-.2373-.0378-.4804.0022-.693.1139-.2127.1117-.3835.2893-.487.5061-.0343.209.0073.4234.1174.6044.11.181.2812.3166.4826.3823.2051.0478.4207.0187.6058-.0819s.3268-.2655.3983-.4637c.0715-.1981.0679-.4156-.0103-.6112-.0782-.1955-.2254-.3556-.4138-.4499z" fill="#0d90e0"/><path d="m70.6125 78.1469c-.0704-.0614-.1207-.1425-.1445-.2328-.0237-.0904-.0198-.1858.0112-.2739.002-.1405-.0503-.2764-.1459-.3794-.0956-.1031-.2272-.1653-.3675-.1739-.2933 0-.3733.1534-.3933.3934.0056.1742-.0286.3475-.1.5066-.2.32-.4533.6067-.6667.9267-.9066-.2533-1.18-.0933-1.1933.74-.0134.1043-.0001.2103.0387.3081.0388.0977.1018.184.1832.2507.0813.0666.1783.1115.2818.1304.1034.0189.21.0111.3096-.0225.351-.148.686-.3312 1-.5467.1706.1356.3776.2176.5949.2354.2172.0178.4348-.0294.6252-.1354.121-.11.2172-.2446.2819-.3948.0648-.1502.0967-.3126.0935-.4761-.0031-.1636-.0413-.3245-.1118-.4721s-.1717-.2785-.297-.3837z" fill="#0d90e0"/><path d="m67.5597 82.8198c-.2248.0333-.4475.0801-.6667.14-.4467-.3467-.8867-.4067-1.0933-.1467-.0962.1764-.1319.3794-.1018.578.0301.1987.1244.382.2684.522.3.2467.5067.1733 1.02-.4133.2467.18.48.3933.7534.0933.0808-.0769.1323-.1795.1457-.2902.0133-.1107-.0122-.2226-.0724-.3165-.0667-.08-.18-.1733-.2533-.1666z" fill="#0d90e0"/><path d="m69.1664 87.1465c-.1832-.0094-.3636.0485-.5072.1626-.1436.1142-.2406.2768-.2728.4574-.0734.3867.16.6667.6066.74.0876.0248.1794.0302.2693.0158.0898-.0143.1754-.048.2509-.0987.0755-.0508.139-.1174.1862-.1951.0472-.0778.0768-.1649.087-.2553.0266-.1918-.0236-.3863-.1397-.5411-.1162-.1549-.2888-.2575-.4803-.2856z" fill="#0d90e0"/><path d="m72.9999 89.1399c-.0866 0-.28-.0534-.34.0866s.1134.26.16.26c.0503-.0006.1-.0116.1459-.0323.0458-.0206.087-.0505.1208-.0877 0 0-.04-.2133-.0867-.2266z" fill="#0d90e0"/><path d="m69.1395 81.3335c.0075-.0749-.001-.1505-.0252-.2218-.0241-.0713-.0633-.1366-.1148-.1915-.0505-.0362-.1089-.0598-.1703-.0691s-.1241-.0039-.183.0158c-.26.1466-.2534.38-.12.6666.2333.0134.5.0867.6133-.2z" fill="#0d90e0"/><path d="m73.0065 80.0737c.0242-.1471-.0086-.2978-.0915-.4217-.083-.1238-.21-.2114-.3552-.245-.098-.0245-.2018-.0096-.289.0415-.0873.0511-.151.1343-.1776.2319-.0475.1017-.0589.2167-.0322.3257.0267.1091.0897.2059.1788.2743.0634.0388.134.0643.2076.0749.0735.0105.1485.006.2202-.0133.0718-.0193.1389-.0531.1971-.0992.0583-.0462.1065-.1037.1418-.1691z" fill="#0d90e0"/><path d="m78.7664 84.2871c.0038-.1542-.0459-.3048-.1408-.4264-.0948-.1215-.2288-.2064-.3792-.2403-.32-.0733-.5333.04-.6.32-.0532.1834-.0379.3799.043.5528.081.173.2221.3106.397.3872.1648-.0041.3229-.0656.4471-.174.1242-.1083.2066-.2567.2329-.4193z" fill="#0d90e0"/><path d="m78.3332 80.6668c.1-.4134-.2133-.7934-.7467-.9134-.0907-.029-.1865-.0383-.2811-.0272s-.1856.0424-.2671.0917c-.0815.0492-.1515.1154-.2053.194-.0537.0785-.0901.1677-.1065.2615-.0425.2257.0028.4591.1268.6525.1239.1933.3171.332.5399.3875.2066.0137.4118-.0426.5823-.16.1706-.1173.2966-.2888.3577-.4866z" fill="#0d90e0"/><path d="m73.6661 81.833c-.0933.0933-.2933.2067-.3733.3733-.08.1667.1266.5134.4066.5534.055.0074.111.0035.1644-.0115s.1032-.0408.1463-.0758.0786-.0784.1043-.1276c.0256-.0492.041-.1031.045-.1585.0023-.1373-.0471-.2705-.1385-.373-.0914-.1026-.2181-.1669-.3548-.1803z" fill="#0d90e0"/><path d="m73.0535 54.2596c.2131.0163.4254-.0409.6013-.1623.176-.1213.305-.2994.3654-.5044.015-.1768-.0408-.3523-.1552-.488-.1144-.1356-.278-.2203-.4548-.2353s-.3524.0408-.488.1552c-.1357.1144-.2203.278-.2353.4548-.0503.1522-.0403.3179.0279.463s.1894.2586.3387.317z" fill="#0d90e0"/><path d="m76.8199 88.7002c-.068-.0724-.1501-.1302-.2412-.1697-.0912-.0394-.1895-.0598-.2888-.0598-.0994 0-.1977.0204-.2888.0598-.0912.0395-.1733.0973-.2412.1697-.0759.1198-.1117.2606-.1021.4021s.064.2762.1554.3846c.075.112.19.1911.3215.2208.1314.0298.2693.008.3852-.0608.2066-.1.6666-.1867.7066-.5267s-.2866-.3066-.4066-.42z" fill="#0d90e0"/><path d="m77.7792 68.7598c-.0173.1024-.001.2076.0466.3.1375.2623.3554.4737.6217.6033.2663.1295.5671.1704.8583.1167.0756-.3423.1556-.6823.24-1.02z" fill="#0d90e0"/><path d="m74.7861 81.6662c.1165.0263.2388.0063.3408-.056.1021-.0622.1759-.1617.2058-.2774.0239-.1237-.0024-.2519-.073-.3563s-.1799-.1765-.3036-.2003c-.0613-.0118-.1243-.0115-.1855.0011-.0611.0125-.1192.0369-.1709.0719-.1044.0707-.1764.1799-.2003.3036-.0001.116.0376.2289.1073.3215.0698.0926.1679.16.2794.1919z" fill="#0d90e0"/><path d="m75.9998 83.4203c.0096-.1126-.0246-.2246-.0953-.3127-.0708-.0881-.1727-.1456-.2847-.1606-.3667-.0467-.6667.3733-.6667.52.0464.1379.1367.257.2571.3388.1203.0819.2642.1221.4096.1145.1164-.0166.2216-.0783.2927-.172.0711-.0936.1025-.2114.0873-.328z" fill="#0d90e0"/><path d="m74.4267 77.2334-.04-.06c.0836.0923.1829.169.2933.2267.0393.0246.084.0393.1303.0429.0463.0035.0927-.0042.1353-.0226.0427-.0183.0802-.0467.1094-.0828s.0492-.0787.0583-.1242c.0224-.0458.0345-.096.0352-.147.0008-.051-.0097-.1016-.0307-.1481s-.052-.0877-.0908-.1208c-.0388-.0332-.0845-.0573-.1337-.0708-.1518-.0122-.3041.0178-.44.0867.0184-.1031-.0031-.2092-.06-.2971s-.1451-.1508-.2466-.1762c-.0736-.0154-.1498-.013-.2223.0069-.0724.0199-.1391.0568-.1945.1077-.0554.0508-.0979.1141-.1239.1846-.0261.0705-.035.1461-.026.2208 0 .32-.18.4133-.3733.5933-.4934-.5067-.92-.4733-1.2267.06-.032.0505-.0588.1041-.08.16-.1533.3933-.0467.62.3533.7333.4.1134.6667.1534.9534-.3266.0557.1571.0915.3206.1066.4866-.0219.1104-.0001.2249.0608.3195.061.0945.1563.1617.2659.1872.1195.0536.2522.0708.3814.0495.1292-.0214.2493-.0803.3453-.1695.64-.7133.6533-.9933.06-1.72z" fill="#0d90e0"/><path d="m74.9791 85.9998c-.1094-.0152-.2208.0067-.3163.0623s-.1696.1417-.2103.2444c-.0178.1194.003.2413.0594.348.0563.1068.1453.1927.2539.2453.1094.0227.2232.0029.3186-.0552.0953-.0582.1649-.1505.1947-.2581.0282-.1175.0134-.2412-.0416-.3488-.055-.1075-.1466-.1919-.2584-.2379z" fill="#0d90e0"/><path d="m78.8194 87.0601c-.0521-.0174-.1071-.0241-.1619-.0197-.0547.0044-.1079.0198-.1565.0454-.0486.0255-.0915.0606-.1262.1032-.0346.0425-.0603.0917-.0755.1444-.0223.1093-.0041.2229.051.3198.0552.0969.1437.1704.249.2069.0515.0207.1067.0301.1621.0276.0553-.0025.1095-.0169.1588-.0422s.0926-.0609.127-.1044.059-.0938.0722-.1476c.0198-.11.0007-.2234-.054-.3207-.0548-.0974-.1418-.1726-.246-.2127z" fill="#0d90e0"/><path d="m69.8128 55.2067c.1204.0138.2421-.012.3467-.0733s.1864-.1549.2333-.2667c.1066-.44-.0467-.8467-.3467-.9067-.171-.0024-.3384.0491-.4784.1473-.14.0981-.2455.2379-.3016.3994-.06.24.26.6533.5467.7z" fill="#0d90e0"/><path d="m66.6134 93.0799c-.0057-.0601-.0315-.1165-.0734-.16z" fill="#0d90e0"/><path d="m65.9993 60.3004c.1466 0 .4133 0 .4666-.1734.0534-.1733-.1266-.38-.2533-.4933s-.2867 0-.3867.12l-.0666.18c-.0133.04-.0173.0824-.0118.1242s.0203.0818.0433.117c.0231.0352.0538.0648.0899.0865.0361.0218.0766.0351.1186.039z" fill="#0d90e0"/><path d="m67.1664 61.333h.4133c.0931-.0709.1966-.1271.3067-.1667.1399-.0288.2645-.1077.3505-.2218.0859-.1142.1272-.2557.1161-.3982 0-.3333-.2266-.6667-.18-.7933.0467-.1267.1534-.3334.3934-.24.24.0933.6266-.0467.7933-.5467s-.2733-1.04-.6667-.9267c-.3933.1134-.4.3534-.5333.2934s-.3133-.3267-.5533-.2267-.2534.8733-.1934.9867c.06.1133.3467.3466.1534.5333-.1934.1867-.5267.2333-.5867.5067-.06.2733-.2266.9133.1267 1.1867z" fill="#0d90e0"/><path d="m68.1727 57.0195c.46.16.76-.2533.9066-.4733.1467-.22-.2666-.6667-.6666-.6667-.1115-.012-.2235.0183-.3137.085-.0901.0667-.1519.1649-.173.275-.1067.2867.0133.7.2467.78z" fill="#0d90e0"/><path d="m71.4802 55.6471c-.0562.2366-.0242.4856.0899.7003.1141.2148.3026.3806.5301.4664.2317.0296.4666-.0202.6663-.1413.1998-.1211.3527-.3063.4337-.5254.1266-.4266-.2-.8666-.78-1.0266-.0963-.0284-.1973-.0372-.2971-.0259-.0997.0112-.1962.0423-.2838.0914-.0875.049-.1644.1151-.2261.1943s-.1069.1699-.133.2668z" fill="#0d90e0"/><path d="m70.2797 56.5466c-.119-.0227-.2421.0004-.3447.0647s-.1772.165-.2087.2819c-.0143.1107.0134.2227.0776.314.0641.0913.1601.1553.2691.1794.0529.0194.1092.0272.1654.0229.0561-.0043.1106-.0206.1599-.0477.0493-.0272.0922-.0647.1258-.1098.0336-.0452.0571-.0971.0689-.1521.0192-.0569.0262-.1171.0205-.1769-.0057-.0597-.0239-.1176-.0535-.1698-.0295-.0522-.0698-.0976-.1181-.1332s-.1036-.0606-.1622-.0734z" fill="#0d90e0"/><path d="m71.153 53.167c.2067.0734.3133-.12.3467-.3066.0333-.1867-.1-.4467-.2934-.42-.1933.0266-.4866 0-.4933.2866-.0067.2867.2933.3934.44.44z" fill="#0d90e0"/><path d="m69.4132 59.913c-.0026.2195.0672.4338.1986.6097.1313.1759.3168.3037.528.3637.2667.0466.5533-.2267.6133-.5934.06-.3666-.0933-.6666-.5533-.7533-.36-.0667-.7333.1067-.7866.3733z" fill="#0d90e0"/><path d="m70.0995 52.6663c.0727-.0122.1422-.0392.2041-.0794.0619-.0401.115-.0925.1559-.1539.0866-.1733-.1467-.4667-.3534-.4467-.114.0303-.2192.0875-.3066.1667-.0467.28.1066.5133.3.5133z" fill="#0d90e0"/><path d="m66.413 80.3798c.133-.0482.2622-.1061.3867-.1734.4133-.2.5533-.46.44-.8066-.0288-.0903-.0751-.174-.1363-.2463-.0611-.0723-.136-.1319-.2202-.1752-.0842-.0434-.1762-.0697-.2706-.0774-.0944-.0078-.1894.0032-.2796.0322-.4359.126-.8821.213-1.3333.26-.3667 0-.78.3067-.7534.56.0383.1099.1107.2048.2066.2707.0959.066.2104.0996.3268.096.32-.1067.4133.12.6066.2333.1941.0891.4067.1302.62.12.1408-.0014.2795-.0332.4067-.0933z" fill="#0d90e0"/><path d="m64.2527 85.5404c-.0784-.0226-.1616-.0226-.24 0l.22.8733c.0897-.0122.1732-.0523.239-.1144.0658-.0622.1104-.1434.1277-.2322.009-.1139-.0202-.2275-.083-.3229s-.1556-.1671-.2637-.2038z" fill="#0d90e0"/><path d="m64.5927 82.4329c-.0253-.1546-.0868-.301-.1795-.4273s-.2139-.2289-.3538-.2994c-.2533-.0666-.44.0934-.5133.4467-.0734.3533 0 .6133.2866.6667.3867.0933.7067-.0734.76-.3867z" fill="#0d90e0"/><path d="m67.0595 80.4529c-.1286-.0341-.2651-.0228-.3865.0318s-.2203.1493-.2801.2682c-.1334.4467 0 .86.28.94.1706.0227.3439-.0122.4924-.0992.1485-.0869.2638-.2209.3276-.3808.0315-.1576.005-.3213-.0746-.461-.0796-.1396-.207-.2458-.3588-.299z" fill="#0d90e0"/><path d="m65.5734 86.9202c-.034-.0022-.0677.0081-.0947.0289-.0271.0209-.0455.0509-.0519.0845 0 .04.0733.1266.1066.1266.0289.0038.0582-.002.0835-.0164.0253-.0145.0451-.0368.0565-.0636.004-.0171.0045-.0347.0016-.052s-.0091-.0339-.0184-.0487c-.0093-.0149-.0214-.0278-.0357-.0379-.0143-.0102-.0304-.0174-.0475-.0214z" fill="#0d90e0"/><path d="m63.4666 75.82c.0578-.1456.0683-.3058.03-.4577-.0383-.152-.1234-.288-.2433-.3889-.0728-.0301-.1521-.041-.2303-.0317-.0782.0094-.1527.0386-.2164.085v.4333.86h.0467c.1891.0311.3831-.0067.5467-.1066.0586-.0451.0981-.1107.1105-.1836.0123-.0729-.0033-.1479-.0439-.2098z" fill="#0d90e0"/><path d="m63.3327 72.2663c.0993-.1541.1366-.3401.1044-.5206-.0321-.1806-.1313-.3422-.2777-.4527.0858-.0603.1583-.1374.2133-.2267.0494-.0826.0754-.177.0754-.2733 0-.0962-.026-.1907-.0754-.2733.0688.0059.138.0059.2067 0 .2453-.0561.4604-.2025.6025-.41.142-.2076.2007-.4611.1642-.71.0651.0204.1349.0204.2 0 .0474-.0272.0886-.0639.121-.1079s.0553-.0942.0672-.1475c.0119-.0534.0126-.1086.002-.1621-.0106-.0536-.0322-.1044-.0636-.1492h-.54c-.0696.0818-.1075.1859-.1066.2933-.0974-.0869-.2182-.1434-.3473-.1624-.1292-.0189-.2611.0004-.3794.0558-.1933 1.2466-.32 2.52-.4 3.8066.14-.1.2933-.2333.4333-.56z" fill="#0d90e0"/><path d="m33.5663 123.433c-.2927 0-.58-.079-.832-.228-.2521-.149-.4597-.362-.6013-.618-.629-1.159-1.1877-2.355-1.6733-3.58-.0784-.2-.1167-.412-.1129-.626s.0498-.425.1352-.622c.0854-.196.2087-.373.3627-.522s.3358-.265.535-.344c.1991-.078.4118-.117.6258-.113s.4251.05.6214.135c.1962.086.3737.209.5223.363s.2655.336.3438.535c.4318 1.102.9328 2.175 1.5 3.214.1374.247.2077.526.2037.809s-.082.56-.2263.803c-.1443.244-.3499.445-.5962.584-.2463.14-.5249.212-.8079.21zm-3.44-10.826c-.4007-.001-.7869-.15-1.0846-.419-.2977-.268-.486-.636-.5287-1.035-.1201-1.073-.1802-2.153-.18-3.233 0-.24 0-.487 0-.733.0122-.431.1943-.84.5065-1.137s.7292-.459 1.1602-.45c.2136.006.424.054.619.141.1951.088.371.213.5177.368.1468.155.2614.338.3375.538.076.2.112.413.1058.626v.667c0 .962.0534 1.924.16 2.88.0467.429-.0787.858-.3487 1.195-.2701.336-.6626.551-1.0913.598zm1.1267-11.274c-.172 0-.3429-.027-.5067-.08-.2031-.066-.3912-.172-.5535-.311-.1622-.139-.2955-.309-.392-.5-.0966-.191-.1547-.399-.1709-.6117-.0162-.2131.0098-.4274.0764-.6305.4094-1.2638.9107-2.496 1.5-3.6866.1919-.3872.5297-.6824.9391-.8205.4095-.1382.8571-.108 1.2443.0838s.6823.5297.8205.9391c.1381.4095.108.857-.0839 1.2442-.5253 1.0472-.971 2.1325-1.3333 3.2462-.1062.326-.312.61-.5884.812-.2763.202-.6092.312-.9516.315zm5.8-9.7332c-.3189 0-.6308-.0937-.8969-.2695-.266-.1759-.4745-.426-.5996-.7194-.125-.2934-.161-.6171-.1036-.9308.0575-.3137.2059-.6036.4268-.8336.9152-.9554 1.8953-1.8464 2.9333-2.6667.1641-.1567.3591-.2773.5725-.3541.2135-.0769.4406-.1082.6669-.0921.2263.0162.4467.0795.6471.1859.2003.1064.3762.2535.5163.432.1402.1784.2414.3841.2973.604s.0651.449.0272.6727c-.0379.2236-.1222.4369-.2475.626-.1252.1892-.2887.3501-.4798.4723-.9195.7312-1.7882 1.5242-2.6 2.3733-.1512.1538-.3319.2756-.5312.358-.1993.0825-.4131.124-.6288.122zm9.3333-6.4333c-.3761.0051-.7424-.1203-1.0364-.3549-.2941-.2346-.4977-.5639-.5763-.9317-.0786-.3679-.0273-.7516.1452-1.0859s.4555-.5984.8009-.7475c1.203-.513 2.4338-.9581 3.6866-1.3333.2045-.0631.4193-.0852.6323-.0652s.4199.0817.6091.1817c.1891.1.3567.2362.4931.401.1365.1647.2392.3547.3022.5591.063.2045.0852.4193.0652.6323s-.0817.4199-.1817.6091c-.1.1891-.2362.3567-.401.4931-.1647.1365-.3547.2392-.5592.3022-1.1339.3465-2.2469.7582-3.3333 1.2333-.2015.0685-.4142.0979-.6267.0867zm33.88-2.6667c-.4314.0045-.8469-.1627-1.1551-.4646-.3082-.302-.4838-.7139-.4882-1.1454-.0044-.4314.1627-.8469.4647-1.1551.3019-.3082.7139-.4838 1.1453-.4882 1.2333 0 2.48-.0667 3.7067-.12.4314-.0203.8532.1315 1.1727.4222.3194.2907.5103.6964.5306 1.1278s-.1315.8532-.4222 1.1727c-.2907.3194-.6964.5103-1.1278.5306-1.26.0534-2.5333.1-3.7933.12zm-7.68 0h-.0466c-1.24-.0311-2.5-.0777-3.78-.14-.2136-.01-.4232-.0621-.6167-.1531-.1935-.0911-.3672-.2193-.5111-.3775s-.2553-.3432-.3277-.5444c-.0725-.2012-.1046-.4147-.0945-.6283.01-.2136.0621-.4232.1531-.6167.0911-.1935.2194-.3671.3775-.5111.1582-.1439.3432-.2553.5444-.3277.2012-.0725.4147-.1046.6283-.0945 1.2533.06 2.4933.1067 3.7133.1333.4143.0312.8012.219 1.0819.5253s.4341.7081.429 1.1235c-.0051.4155-.1683.8133-.4564 1.1127-.2882.2993-.6795.4776-1.0945.4985zm-15.1733-.1066c-.4214.0075-.8292-.1488-1.1376-.436-.3085-.2872-.4934-.6829-.5159-1.1037-.0224-.4209.1193-.834.3954-1.1524s.665-.5172 1.0848-.5546c1.2533-.14 2.5533-.2267 3.8666-.2667.2151-.0107.4302.022.6323.0963s.3871.1887.544.3362.2823.3252.3689.5224.1324.4098.1348.6251c.0063.2137-.0297.4264-.1057.6262-.0761.1997-.1908.3825-.3375.5379s-.3226.2804-.5177.3678c-.1951.0873-.4054.1354-.6191.1415-1.2266.04-2.4466.12-3.6133.2466zm34.26-.5534c-.4199.0035-.8248-.1555-1.1302-.4437-.3053-.2883-.4873-.6834-.508-1.1028s.1217-.8305.3972-1.1474.6628-.5149 1.081-.5527c1.2333-.1267 2.4733-.2734 3.6733-.4334.2119-.028.4272-.014.6337.0412.2064.0552.4.1505.5696.2805s.312.2922.419.4772c.1069.185.1764.3893.2044.6011.028.2119.014.4272-.0412.6337-.0552.2064-.1505.4-.2805.5696s-.2921.312-.4771.419c-.1851.1069-.3893.1764-.6012.2044-1.2333.1667-2.5.3133-3.7733.4467zm11.333-1.7066c-.402-.0003-.791-.15-1.09-.4201-.298-.2702-.486-.6416-.527-1.0424s.069-.8024.307-1.1271c.239-.3247.589-.5493.984-.6304 1.213-.2534 2.426-.52 3.6-.8134.42-.1025.863-.034 1.233.1904.369.2244.634.5864.737 1.0063.102.4199.034.8634-.191 1.2328-.224.3695-.586.6346-1.006.7372-1.213.2933-2.467.5733-3.72.8333-.114.0018-.228-.0071-.34-.0266zm10.994-3.0267c-.386.0007-.759-.1357-1.054-.3848-.294-.249-.49-.5946-.554-.975-.063-.3804.011-.7709.209-1.1019.198-.3309.507-.5808.872-.705 1.167-.4 2.327-.8266 3.44-1.2733.401-.16.85-.154 1.247.0166.397.1707.71.492.87.8934s.154.8498-.017 1.2468-.492.7099-.893.8699c-1.167.46-2.38.9066-3.594 1.3333-.17.0557-.348.0827-.526.08zm10.473-4.5467c-.36-.0033-.709-.1257-.993-.3482-.284-.2224-.486-.5324-.575-.8816s-.06-.7181.083-1.0491c.142-.331.39-.6056.705-.7811 1.087-.5866 2.14-1.2066 3.14-1.84.18-.1147.382-.1927.592-.2296.211-.037.426-.0321.635.0143.209.0465.406.1335.581.2563.175.1227.324.2787.439.459.114.1804.192.3816.229.5921s.032.4262-.014.6348-.134.4061-.256.5811c-.123.1749-.279.324-.459.4387-1.06.6667-2.18 1.3333-3.334 1.9533-.228.128-.485.1968-.746.2zm9.333-6.52c-.328-.0008-.648-.101-.919-.2874-.27-.1863-.478-.4502-.595-.7567-.118-.3066-.14-.6416-.063-.9609.076-.3194.247-.6081.491-.8283.9-.82 1.76-1.6667 2.553-2.5267.292-.3182.698-.5076 1.129-.5263.432-.0188.853.1346 1.171.4263.318.2918.508.698.526 1.1293.019.4314-.134.8525-.426 1.1707-.86.9334-1.793 1.86-2.78 2.7467-.285.2555-.651.4019-1.033.4133zm7.167-8.8333c-.283.0005-.562-.0729-.808-.213-.246-.1402-.451-.3421-.595-.5859-.144-.2437-.222-.5209-.226-.8041-.004-.2831.065-.5625.202-.8103.347-.6667.667-1.28.967-1.92.193-.44.393-.88.593-1.3334.088-.1956.213-.3721.369-.5194s.339-.2624.539-.3389c.2-.0764.414-.1127.628-.1067s.425.0542.621.1417.372.2128.519.3685c.148.1558.263.3391.339.5393.077.2003.113.4136.107.6279s-.054.4253-.142.621l-.606 1.3333c-.334.7267-.667 1.4467-1.087 2.1533-.134.2483-.331.4572-.57.6057-.24.1486-.515.2317-.797.241zm-9.133-8.0867c-.208-.0011-.414-.0419-.607-.12-1.199-.4887-2.37-1.0452-3.507-1.6666-.195-.0972-.369-.2328-.511-.3986-.142-.1659-.249-.3586-.315-.5667s-.089-.4274-.069-.6447c.021-.2173.085-.4283.189-.6203s.245-.3612.416-.4974c.17-.1362.367-.2367.577-.2954.21-.0588.43-.0746.647-.0466.216.028.425.0993.613.2097 1.048.57 2.127 1.0797 3.233 1.5266.354.1394.648.3983.831.7318.182.3334.242.7203.17 1.0935-.073.3732-.275.709-.569.9492-.295.2402-.665.3695-1.045.3655zm13.586-2.4533c-.172-.0005-.343-.0275-.506-.08-.204-.0671-.392-.1736-.554-.3136-.162-.1399-.295-.3104-.391-.5018s-.153-.3999-.168-.6135c-.016-.2135.012-.428.079-.6311.394-1.2067.747-2.38 1.027-3.4933.107-.4182.376-.7767.747-.9968.371-.22.815-.2835 1.233-.1766.418.107.777.3757.997.747.22.3714.283.8149.176 1.233-.3 1.1867-.666 2.4334-1.093 3.7134-.108.3246-.316.607-.593.8068-.278.1999-.612.3072-.954.3065zm-23.086-3.6933c-.218.0007-.434-.0426-.634-.1274-.201-.0848-.382-.2093-.533-.366-.954-.975-1.803-2.0475-2.533-3.2-.231-.3651-.307-.8069-.212-1.2282.095-.4214.353-.7877.718-1.0184.365-.2308.807-.307 1.229-.212.421.095.787.3535 1.018.7186.608.9614 1.316 1.8553 2.113 2.6667.223.2293.373.5192.432.8334s.024.6388-.1.9333c-.125.2944-.333.5457-.6.7224-.266.1766-.579.2709-.898.2709zm25.333-7.4867h-.04c-.214-.0052-.424-.0524-.62-.139-.195-.0866-.371-.2108-.519-.3656-.147-.1548-.262-.3371-.339-.5365-.077-.1993-.114-.4119-.109-.6256 0-.2133 0-.42 0-.6266.004-.9481-.063-1.8952-.2-2.8334-.031-.2114-.021-.427.031-.6344.052-.2073.144-.4025.271-.5743s.287-.3168.47-.4269c.184-.11.387-.1828.598-.2144.212-.0315.427-.021.635.0307.207.0518.402.144.574.2712s.317.287.427.4703.183.3864.214.5978c.165 1.1035.245 2.2178.24 3.3334v.7133c-.017.4256-.201.8274-.511 1.1191s-.723.45-1.149.4409zm-30-2.6667c-.416.0119-.82-.1353-1.131-.4116s-.505-.6608-.542-1.075c0-.42-.04-.8534-.04-1.28v-.1067c.006-.9106.077-1.8196.213-2.72.033-.2114.108-.4142.22-.5968.111-.1826.258-.3415.431-.4674.173-.126.369-.2167.577-.2669.208-.0501.424-.0588.635-.0256.212.0333.415.1079.597.2195.183.1117.342.2582.468.4312.126.1731.216.3692.266.5773.051.208.059.4239.026.6354-.115.739-.175 1.4855-.18 2.2333v.0333c0 .36 0 .7067.04 1.0534.029.4312-.113.8565-.397 1.1827-.284.3261-.685.5265-1.116.5573zm26.893-8.0933c-.248.0011-.493-.0549-.716-.1635-.223-.1087-.418-.2671-.57-.4632-.688-.8757-1.483-1.6619-2.367-2.34-.169-.1313-.311-.2947-.418-.4808-.106-.1862-.174-.3915-.201-.6041-.055-.4295.064-.8629.329-1.2051.265-.3421.655-.5649 1.085-.6192.429-.0544.863.064 1.205.3292 1.103.8456 2.095 1.8266 2.953 2.92.187.2401.303.5279.335.8307.032.3027-.022.6083-.155.8819-.134.2737-.341.5044-.599.666s-.556.2475-.861.2481zm-23.533-2.5c-.316.0003-.625-.0914-.89-.2639s-.473-.4183-.6-.7075c-.128-.2892-.168-.6092-.116-.9209.051-.3117.192-.6016.406-.8344.965-1.058 2.089-1.9578 3.333-2.6666.375-.214.82-.2702 1.236-.1565.416.1138.77.3883.984.7631.214.3749.271.8193.157 1.2357-.114.4163-.388.7704-.763.9843-.941.5319-1.793 1.2066-2.527 2-.152.1796-.343.3235-.557.4214s-.447.1475-.683.1453zm14-3.4667c-.142-.0002-.283-.0204-.42-.06-1.089-.2946-2.207-.4668-3.333-.5133-.214-.0086-.424-.0592-.618-.149s-.369-.2169-.513-.3742c-.145-.1573-.258-.3416-.331-.5424-.074-.2007-.107-.4141-.098-.6277.024-.4292.214-.832.53-1.1232s.733-.4479 1.163-.4368c1.37.0567 2.73.2669 4.053.6266.383.1006.716.3374.937.666s.315.7265.263 1.1191c-.051.3925-.244.7529-.543 1.0133-.298.2605-.681.4033-1.077.4016z" fill="#294492"/><path d="m139.447 49.7266c-.62.4333-8.36 5.0333-15.687 6.2466 3.363 3.1587 5.851 7.1336 7.223 11.5384s1.581 9.0897.606 13.5991c-.974 4.5095-3.098 8.6901-6.167 12.1355-3.068 3.4455-6.975 6.0385-11.342 7.5268.42 5.374.98 10.74 1.58 16.1 6.853-1.696 13.179-5.066 18.409-9.808s9.202-10.7073 11.56-17.362c2.357-6.6547 3.026-13.7904 1.947-20.7674s-3.872-13.5772-8.129-19.209z" fill="#1ba9f5"/><path d="m133.333 107.814c.021-.047.037-.097.047-.147-.207.18-.4.36-.607.527.121-.001.239-.037.338-.105.1-.068.177-.164.222-.275z" fill="#0066b1"/><path d="m137.907 105.913c.183.031.371.015.545-.047.175-.062.332-.167.455-.306.021-.128.004-.259-.048-.377s-.137-.219-.246-.29c0-.226-.18-.366-.52-.44-.143-.028-.293 0-.416.078-.124.078-.214.2-.25.342.004.186.062.367.166.52.008.106.04.208.094.298.055.09.13.167.22.222z" fill="#0066b1"/><path d="m139.187 104.52c.313.073.642.03.926-.12l.047-.04c.077.006.155-.012.222-.05.068-.037.123-.095.158-.164.039-.17.032-.349-.022-.515-.054-.167-.153-.316-.285-.431-.06-.03-.126-.046-.193-.049s-.134.008-.197.033c-.063.024-.12.061-.167.109-.048.047-.085.104-.109.167-.146.003-.288.049-.407.134-.118.084-.209.202-.26.339-.073.213.087.573.287.587z" fill="#0066b1"/><path d="m138.666 103.333c0-.053-.04-.167-.093-.187-.157-.089-.281-.227-.353-.393-.154.18-.3.367-.46.547.17.092.346.175.526.246.154.1.274.014.38-.213z" fill="#0066b1"/><path d="m137.373 103.713c-.067.08-.134.153-.207.227.037-.01.073-.023.107-.04.029-.023.052-.051.07-.083.017-.032.027-.068.03-.104z" fill="#0066b1"/><path d="m137 107.7c.011.062.041.12.084.167.044.046.101.078.163.093.106 0 .193-.154.186-.26-.006-.107-.113-.234-.226-.214-.055.005-.106.029-.143.069-.038.039-.061.09-.064.145z" fill="#0066b1"/><path d="m137.653 112.04c.066-.167.106-.354-.1-.467-.117-.045-.247-.043-.362.006-.116.048-.208.139-.258.254.08.107.153.293.293.38s.353.02.427-.173z" fill="#0066b1"/><path d="m135.693 110.733c.16.105.35.154.54.14.111.032.225.05.34.053.06-.1.12-.193.167-.286.057-.039.11-.081.16-.127-.031-.192-.083-.379-.153-.56-.061-.138-.166-.251-.299-.321-.134-.071-.287-.094-.435-.066-.168.038-.317.133-.422.269-.105.137-.158.306-.151.478.012.083.04.164.083.236.044.072.102.135.17.184z" fill="#0066b1"/><path d="m140.786 99.9467-.26-.2533-.193.28c.151.0316.307.022.453-.0267z" fill="#0066b1"/><path d="m130.133 110.227.106.113c.032-.035.057-.076.074-.12.003-.044.003-.089 0-.133z" fill="#0066b1"/><path d="m141.639 105.426c.071-.028.135-.072.185-.129.051-.056.088-.124.109-.197.013-.055.015-.112.005-.167-.009-.056-.029-.109-.059-.157-.029-.047-.068-.089-.114-.121-.046-.033-.097-.056-.152-.069-.32-.093-.54.22-.6.4s.28.534.626.44z" fill="#0066b1"/><path d="m130.354 110.42.32.32c-.014-.08-.053-.153-.11-.211-.057-.057-.131-.095-.21-.109z" fill="#0066b1"/><path d="m138.453 110.1c-.028-.154-.092-.3-.185-.426-.094-.126-.215-.229-.355-.3-.064-.017-.13-.017-.194 0h-.04c-.076.044-.14.106-.188.18-.047.074-.077.159-.085.246-.087.394 0 .607.287.667.386.113.706-.053.76-.367z" fill="#0066b1"/><path d="m136.626 112.347c.147-.033.407-.16.393-.287-.018-.071-.052-.138-.101-.194-.049-.055-.11-.098-.179-.126-.106 0-.433.114-.453.24-.02.127.273.38.34.367z" fill="#0066b1"/><path d="m143.66 111.333h-.08c-.067-.137-.184-.242-.327-.293-.236-.039-.479 0-.692.111-.212.11-.383.286-.488.502-.037.209.003.425.114.607.11.181.283.316.486.38.206.05.423.023.61-.076.187-.1.331-.265.403-.464.034-.147.034-.3 0-.447.023-.012.041-.031.054-.053.017-.021.03-.047.035-.074.006-.027.004-.055-.003-.081-.008-.027-.023-.051-.042-.07-.02-.02-.044-.034-.07-.042z" fill="#0066b1"/><path d="m142.993 108.993c.011-.074.005-.149-.018-.219-.024-.071-.063-.135-.116-.188-.05-.039-.109-.064-.172-.075-.063-.01-.127-.005-.188.015-.253.147-.253.38-.113.667.233.02.493.093.607-.2z" fill="#0066b1"/><path d="m141.713 112.893c.047.088.124.156.217.192.093.035.196.036.289.002.207-.1.214-.254 0-.667-.067-.021-.138-.023-.207-.007-.068.015-.132.049-.183.097s-.089.109-.11.176c-.02.067-.022.139-.006.207z" fill="#0066b1"/><path d="m144.666 104.886c-.078-.036-.163-.055-.25-.055-.086 0-.171.019-.25.055-.088-.075-.197-.121-.313-.133-.287 0-.367.153-.387.393-.009.126-.029.251-.06.374-.005-.031-.018-.059-.038-.082-.02-.024-.046-.042-.075-.052-.021-.008-.044-.012-.067-.011s-.046.007-.066.017c-.021.01-.039.024-.054.041-.015.018-.026.038-.033.06 0 .093-.053.28.087.353h.06c-.167.24-.36.48-.554.727-.9-.253-1.173-.093-1.186.747-.014.104 0 .21.038.308.039.097.102.184.184.25.081.067.178.112.281.131.104.019.21.011.31-.023.161-.053.315-.124.46-.213.247.187.473.48.86.467l.327-.314c.42.067.806-.046.873-.28.023-.064.029-.134.017-.202-.011-.068-.04-.131-.084-.184.12-.232.161-.496.118-.754-.043-.257-.169-.493-.358-.673-.053-.051-.096-.112-.126-.18.098.077.222.113.346.1.22-.1.174-.733-.06-.867z" fill="#0066b1"/><path d="m147.507 106.726h.08l-.4-.4c-.002.094.029.185.087.258.059.073.141.123.233.142z" fill="#0066b1"/><path d="m146.846 106-.94-.934-.066.094c-.029.053-.053.109-.074.166-.153.387-.046.614.354.727.116.051.243.073.369.063.127-.009.25-.049.357-.116z" fill="#0066b1"/><path d="m146.866 107.747c.017-.076.018-.153.005-.229-.014-.076-.042-.149-.083-.214-.042-.065-.096-.121-.159-.165s-.134-.076-.21-.092c-.048-.014-.099-.018-.149-.012-.051.006-.099.023-.143.048-.043.026-.081.06-.111.101s-.052.087-.063.136c-.049.101-.06.215-.034.323.027.108.091.204.18.27.061.041.13.069.202.082.071.014.145.013.217-.003.071-.015.139-.045.199-.087s.11-.096.149-.158z" fill="#0066b1"/><path d="m140.547 109.333c.17.022.342-.013.489-.1.148-.087.262-.221.324-.38.031-.159.003-.324-.08-.464-.082-.14-.212-.245-.366-.296-.083-.02-.17-.021-.253-.002-.083.018-.161.056-.227.109.048-.092.081-.191.1-.294l.12-.053c.42-.207.56-.467.446-.813-.059-.182-.188-.332-.358-.419-.169-.088-.366-.105-.548-.048-.437.125-.883.214-1.334.267-.36 0-.773.3-.746.553.026.253.42.407.533.367.287-.1.393.08.56.2-.003.031-.003.062 0 .093.035.128.106.243.204.333.098.089.219.15.349.174.089.015.181.01.267-.015.087-.026.167-.071.233-.132-.126.467 0 .867.287.92z" fill="#0066b1"/><path d="m141.72 103.253c-.374-.147-.494.213-.7.453.193.32.366.567.733.447.05-.01.097-.03.138-.059.042-.029.077-.066.104-.11.027-.043.045-.091.052-.141.008-.05.005-.101-.007-.15-.045-.183-.16-.341-.32-.44z" fill="#0066b1"/><path d="m141.693 101.02c.043.233.157.447.327.613.065.061.151.094.24.094s.174-.033.24-.094l-.794-.793c-.017.058-.022.12-.013.18z" fill="#0066b1"/><path d="m143.113 103.567c-.139-.017-.28.017-.395.097-.116.08-.198.2-.231.336-.017.077-.016.157.001.234s.05.15.097.213c.048.063.108.115.177.153s.146.061.225.067c.125.009.249-.031.345-.112s.156-.197.168-.322c.053-.306-.167-.66-.387-.666z" fill="#0066b1"/><path d="m143.899 103.227c.047.006.094.006.14 0l-.533-.534c-.007.047-.007.094 0 .14.003.104.046.202.119.275s.171.115.274.119z" fill="#0066b1"/><path d="m134.973 107.933c-.16.367-.086.667.18.78.173.032.351.014.514-.053.052.244.195.458.4.6.4.207.92.153.973-.247.023-.361-.081-.72-.293-1.013-.14-.26-.507-.167-.374-.487.114-.255.144-.54.087-.813.133-.017.26-.066.37-.143s.2-.178.263-.297c.024-.117.008-.239-.045-.347-.053-.107-.14-.194-.248-.246-.44-.154-.8-.074-.893.206-.032.16-.003.326.08.467-.157-.007-.312.032-.446.113-.134.08-.241.199-.308.34-.094-.188-.252-.337-.446-.42-.347.334-.7.66-1.06.98.028.123.091.235.181.324.089.088.202.149.325.176.193.001.382-.047.551-.139s.312-.226.416-.387c.019.072.053.14.1.2-.079.042-.149.1-.205.17s-.097.15-.122.236z" fill="#0066b1"/><path d="m138.393 102.54c.313.666.613.766 1.186.466.434-.22.874-.426 1.334-.633.533.84.666.867 1.18.327-.007-.142-.058-.277-.147-.388-.089-.11-.21-.189-.347-.226.048-.035.085-.084.107-.14.02-.046.03-.096.029-.146s-.012-.1-.034-.145c-.021-.045-.052-.086-.09-.118-.039-.033-.083-.057-.132-.071-.04-.02-.083-.031-.128-.033-.045-.003-.089.004-.131.02s-.08.04-.112.071c-.033.031-.058.068-.075.109-.44-.747-1.034-.72-1.687.067-.075-.016-.149-.039-.22-.067-.233.3-.473.593-.713.887z" fill="#0066b1"/><path d="m144.16 116.447c.064-.116.088-.25.069-.382-.019-.131-.081-.252-.176-.345-.128-.131-.268-.25-.42-.353-.01-.033-.024-.064-.04-.094 0-.073 0-.153 0-.233-.003-.181-.06-.356-.163-.505-.103-.148-.248-.262-.417-.328-.177-.039-.36-.033-.534.019-.174.051-.332.145-.459.274-.092.105-.142.24-.14.38.001.131.033.261.095.377.061.116.15.215.258.29 0 .326.2.573.607.633.047.003.093.003.14 0v.107c0 .346.127.473.46.513.142.027.289.007.419-.056.13-.064.236-.168.301-.297z" fill="#0066b1"/><path d="m146.373 114c-.167 0-.587.073-.614.24-.026.167.314.393.46.473.147.08.394-.113.414-.326.02-.214-.047-.387-.26-.387z" fill="#0066b1"/><path d="m147.52 109.5c-.094.093-.294.207-.367.373-.073.167.127.514.407.554.054.006.11.002.163-.014.053-.015.103-.041.145-.076.043-.035.079-.078.105-.126.026-.049.042-.103.047-.158 0-.138-.051-.271-.143-.373-.093-.103-.22-.167-.357-.18z" fill="#0066b1"/><path d="m145.393 118.54c-.085.032-.154.096-.193.178s-.044.176-.013.262c.086.34.353.38.666.394.147-.26.36-.48.127-.74-.073-.081-.17-.134-.277-.151s-.216.003-.31.057z" fill="#0066b1"/><path d="m146.86 116.807c-.087 0-.28-.053-.347.087-.066.14.12.26.167.26.05 0 .1-.01.147-.031.046-.021.087-.051.12-.089 0 0-.04-.214-.087-.227z" fill="#0066b1"/><path d="m145.006 112c.153.06.347 0 .327-.22s-.06-.52-.227-.553c-.167-.034-.36.333-.38.466-.02.134.16.307.28.307z" fill="#0066b1"/><path d="m145.333 114.707c.058-.034.107-.083.14-.141.034-.059.051-.125.051-.192 0-.068-.017-.134-.051-.192-.033-.059-.082-.108-.14-.142-.353-.12-.573.287-.5.487.05.083.127.146.218.179s.191.033.282.001z" fill="#0066b1"/><path d="m148.833 113.646c-.108-.015-.218.006-.312.061-.094.054-.167.139-.208.239-.019.119 0 .241.055.348s.144.193.252.246c.109.024.224.005.32-.053.095-.059.165-.152.193-.261.031-.116.017-.24-.038-.347-.056-.107-.149-.19-.262-.233z" fill="#0066b1"/><path d="m152.626 111.953c.01-.058.01-.116 0-.174l-.513-.52c-.32-.066-.527.04-.593.32-.1.4.119.88.433.94.16-.004.313-.062.435-.165.123-.103.207-.245.238-.401z" fill="#0066b1"/><path d="m152.666 114.726c-.052-.017-.107-.024-.161-.02-.055.005-.108.02-.157.046-.048.025-.091.06-.126.103s-.06.092-.076.144c-.022.109-.005.222.049.319.054.096.141.17.245.208.051.021.106.03.162.028.055-.003.109-.017.159-.042.049-.026.092-.061.127-.105.034-.043.059-.094.072-.148.021-.109.004-.222-.05-.32-.053-.097-.14-.173-.244-.213z" fill="#0066b1"/><path d="m150.667 116.367c-.069-.071-.151-.128-.242-.167s-.189-.059-.288-.059c-.1 0-.198.02-.289.059s-.173.096-.241.167c-.076.12-.112.261-.103.402.01.142.065.276.156.385.074.111.187.19.318.22.13.03.267.008.382-.06.213-.1.667-.187.707-.527s-.274-.307-.4-.42z" fill="#0066b1"/><path d="m149.866 111.087c.008-.113-.028-.225-.1-.313s-.174-.146-.287-.161c-.154-.003-.305.046-.426.141-.122.095-.207.229-.241.379.048.144.144.268.271.35.127.083.279.119.429.104.111-.024.209-.089.274-.182.066-.092.094-.206.08-.318z" fill="#0066b1"/><path d="m145.12 110.667c.18.016.36-.028.513-.124.153-.097.27-.24.334-.41.066-.26-.214-.606-.547-.666-.433-.094-.667 0-.767.346-.03.174-.001.354.084.509s.22.276.383.345z" fill="#0066b1"/><path d="m148.666 109.333c.118.024.24.001.341-.064.101-.064.172-.166.199-.283.018-.128-.016-.259-.093-.363-.077-.105-.192-.175-.32-.197-.118-.012-.237.02-.333.09s-.163.174-.187.29c-.005.12.031.238.103.334.071.096.174.164.29.193z" fill="#0066b1"/><path d="m138.886 117.133c-.101-.039-.21-.056-.318-.049s-.214.037-.309.089c-.024-.079-.07-.149-.132-.202-.063-.053-.14-.087-.221-.098-.314-.06-.447.2-.594.453.06.087.121.187.181.267l.093.087c.05.044.112.073.178.084.066.01.134.002.195-.024l.087-.054c.025.081.064.158.113.227.006.031.018.06.037.086.018.025.042.046.07.061.083.111.196.197.326.246.057.019.117.025.177.019.059-.005.117-.023.169-.052.053-.028.099-.067.136-.114.036-.048.063-.102.078-.159.074-.152.087-.325.037-.486-.049-.161-.158-.297-.303-.381z" fill="#0066b1"/><path d="m137.793 115.454c.007-.056.007-.112 0-.167.25-.061.468-.216.606-.433.174-.267 0-.567-.04-.88.05-.061.086-.131.107-.207.019-.125-.013-.251-.087-.353-.033-.667-.613-.86-1.173-.86-.215.002-.424.074-.593.206-.059.051-.112.107-.16.167-.132-.135-.302-.226-.487-.26-.353-.067-.593.147-.667.593-.009.142.027.284.104.404s.19.212.323.263c.164.007.328-.029.473-.107-.055.088-.093.185-.11.287s-.014.206.01.307c.071.203.186.388.337.542.151.153.334.271.537.344v.04c-.04.174.346.374.506.427s.3-.08.314-.313z" fill="#0066b1"/><path d="m135.86 112.5c.097-.212.121-.452.067-.679-.053-.228-.18-.432-.361-.581-.169-.104-.371-.141-.567-.106-.195.036-.371.143-.493.3-.08.08-.141.176-.179.283-.038.106-.051.22-.04.332.012.112.048.221.107.317.058.097.138.179.232.241.18.145.409.215.638.195.23-.02.444-.128.596-.302z" fill="#0066b1"/><path d="m134.793 110.153c.175-.116.3-.294.351-.498s.025-.419-.074-.604c-.099-.186-.263-.328-.461-.399s-.415-.066-.609.015c-.313.113-.367.666-.227 1.093.093.178.248.317.436.389.188.073.396.074.584.004z" fill="#0066b1"/><path d="m134.326 114.133c-.072.017-.14.049-.2.093l.973.974c.035-.129.053-.261.054-.394-.035-.192-.138-.366-.289-.489-.152-.123-.343-.189-.538-.184z" fill="#0066b1"/><path d="m133.02 109.14c-.094-.035-.193-.052-.293-.048s-.199.029-.289.072-.171.103-.237.179c-.066.075-.117.162-.148.257-.14.36.187.88.667 1.06.085.029.174.041.264.034.089-.007.176-.031.256-.072s.15-.098.207-.167c.058-.069.1-.149.126-.235.173-.64.013-.78-.553-1.08z" fill="#0066b1"/><path d="m144 120.36c-.247-.14-.627.067-.813.44-.042.114-.044.239-.003.353.04.115.119.212.223.274.136.061.289.075.434.039.145-.037.274-.121.366-.239.063-.147.077-.311.04-.467-.037-.157-.124-.297-.247-.4z" fill="#0066b1"/><path d="m132.666 110.96c-.099-.031-.205-.041-.309-.029-.103.011-.204.045-.294.097-.09.053-.169.124-.23.209-.061.084-.105.181-.127.283-.013.088-.013.178 0 .266l1.054 1.047c.226-.033.32-.207.413-.387.066-.106.118-.22.153-.34.034-.052.061-.108.08-.166.1-.427-.2-.84-.74-.98z" fill="#0066b1"/><path d="m141.093 116.706c.059-.193.045-.401-.038-.585-.084-.184-.231-.331-.415-.415-.07-.026-.145-.038-.221-.036-.075.003-.149.021-.217.052-.068.032-.13.077-.18.133-.051.055-.09.12-.115.191-.078.162-.102.343-.07.52.033.176.12.337.25.46.178.075.377.085.562.026.184-.059.342-.181.444-.346z" fill="#0066b1"/><path d="m131.286 109.38c-.05.045-.091.099-.12.16-.028.06-.044.126-.046.194 0 .133-.074.253 0 .386.073.134.293.094.406-.073.097-.162.128-.356.087-.54-.008-.034-.024-.065-.047-.09-.023-.026-.051-.046-.083-.059-.032-.012-.067-.017-.101-.013-.035.004-.067.016-.096.035z" fill="#0066b1"/><path d="m141.553 118.753c-.136-.062-.289-.078-.435-.047-.147.031-.279.108-.378.22-.061.171-.062.357-.003.528.06.171.176.316.329.412.163.051.339.042.496-.024.157-.065.286-.184.364-.336.154-.306.027-.56-.373-.753z" fill="#0066b1"/><path d="m141.38 113.16c-.168-.035-.344-.01-.496.068-.153.079-.274.208-.344.365-.034.127-.018.261.044.376.062.116.165.203.289.244.159.029.322 0 .461-.082.138-.082.242-.212.293-.365.025-.055.038-.116.039-.177s-.01-.122-.033-.179c-.023-.056-.057-.108-.101-.151-.043-.043-.095-.077-.152-.099z" fill="#0066b1"/><path d="m140.36 111.62c.054-.008.106-.026.153-.054.054-.029.101-.07.137-.12.037-.049.063-.106.076-.166l.1-.114c.247.18.48.394.747.094.083-.075.136-.178.151-.289s-.011-.224-.071-.318c-.053-.073-.16-.173-.233-.167l-.24.04c.039-.048.069-.103.087-.163s.024-.123.017-.185c-.007-.063-.026-.123-.057-.177-.03-.055-.071-.103-.121-.141-.07-.061-.152-.107-.24-.135-.089-.027-.183-.036-.275-.025-.092.01-.181.04-.261.087s-.15.111-.204.186c-.088.108-.147.236-.173.373-.06 0-.12.013-.175.037s-.104.059-.145.103c-.093.178-.127.38-.097.578s.122.381.264.522c.07.078.168.126.273.132.104.006.207-.029.287-.098z" fill="#0066b1"/><path d="m139.193 114.48c-.193.174-.393.367-.26.667.019.053.05.101.089.141s.086.072.138.093c.053.02.109.03.165.028.056-.003.111-.017.161-.042.075-.037.139-.091.19-.156.051-.066.086-.143.104-.224 0-.32-.267-.467-.587-.507z" fill="#0066b1"/><path d="m175.172 25.7409c1.935-2.0964 2.189-5.0108.566-6.5095s-4.508-1.0142-6.444 1.0821c-1.936 2.0964-2.189 5.0108-.566 6.5095 1.623 1.4988 4.508 1.0143 6.444-1.0821z" fill="#294492"/><path d="m155.779 18.3999c.8 2.2066 6.88 2.6333 9.067 2.72.237.0093.472-.0414.685-.1475.212-.106.394-.264.528-.4592l.267-.3867c.132-.1934.213-.4167.236-.6497.024-.2329-.012-.4679-.103-.6836-.853-2-3.426-7.54-5.766-7.5-2.134.04-3.827 2.62-3.827 2.62s-1.813 2.4867-1.087 4.4867z" fill="#7de2d1"/><path d="m159.213 12.0533c-.246 2.3267 5.034 5.38 6.96 6.4067.206.1086.435.1653.667.1653s.461-.0567.667-.1653l.406-.2334c.216-.1126.399-.2793.531-.4838s.209-.4398.223-.6828c.106-2.1867.22-8.28002-1.907-9.27335-1.933-.9-4.587.66666-4.587.66666s-2.74 1.48-2.96 3.59999z" fill="#7de2d1"/><path d="m160.666 11.2737c-.491.0173-.973.1473-1.406.38-.035.1312-.059.265-.074.4-.26 2.4133 5.42 5.5933 7.154 6.5133-.907-2.1667-3.38-7.3333-5.674-7.2933z" fill="#42d4c6"/><path d="m188.206 27.78c-.8-2.2-6.88-2.6667-9.066-2.7133-.237-.01-.473.0404-.685.1465-.212.1062-.394.2645-.528.4601l-.261.3867c-.133.1925-.216.4157-.24.6487-.025.2331.01.4686.1.6846.86 2 3.427 7.5467 5.774 7.5067 2.133-.04 3.826-2.62 3.826-2.62s1.807-2.4933 1.08-4.5z" fill="#7de2d1"/><path d="m184.78 34.1331c.247-2.3333-5.04-5.38-6.967-6.4133-.205-.1103-.434-.168-.666-.168-.233 0-.462.0577-.667.168l-.407.2266c-.215.1139-.396.2822-.526.4881-.13.2058-.204.4421-.214.6853-.113 2.1866-.22 8.2866 1.907 9.2733 1.933.9 4.587-.6667 4.587-.6667s2.726-1.4733 2.953-3.5933z" fill="#7de2d1"/><path d="m177.619 27.6201c.933 2.16 3.413 7.3333 5.68 7.2933.492-.0195.974-.1518 1.407-.3866.031-.1297.056-.261.073-.3934.253-2.4133-5.447-5.5999-7.16-6.5133z" fill="#42d4c6"/><path d="m57.1063 44.1738h-56.773292v7.4334h56.773292z" fill="#00bfb3"/><path d="m85.1598 61.3262h-60.0133v7.4333h60.0133z" fill="#ff957d"/><path d="m63.333 68.7597h21.8333v-7.4267h-19.96c-.8493 2.4133-1.4763 4.8992-1.8733 7.4267z" fill="#fa744e"/><path d="m153.753 43.6996c-.332.0022-.656-.0968-.929-.2838-.274-.187-.484-.4531-.602-.7627-.118-.3095-.139-.6478-.059-.9695.079-.3217.254-.6115.503-.8306.9-.7867 1.833-1.6267 2.767-2.4867.323-.2616.733-.3902 1.147-.3598.415.0304.802.2176 1.083.5234.281.3059.435.7075.431 1.1229-.004.4155-.167.8137-.455 1.1135-.96.88-1.906 1.7333-2.826 2.5333-.293.2577-.67.3998-1.06.4zm8.353-7.82c-.322-.0001-.637-.0957-.904-.2749-.268-.1792-.477-.4337-.599-.7315-.123-.2977-.155-.6252-.091-.9409.063-.3158.219-.6055.447-.8327 1.6-1.5933 2.594-2.6666 2.6-2.6666.302-.2883.703-.4496 1.12-.4506s.819.1584 1.122.4452.484.6791.506 1.0957c.022.4167-.116.8259-.388 1.143-.04.04-1.033 1.0867-2.666 2.7133-.301.3117-.714.4914-1.147.5z" fill="#294492"/><path d="m54.22 21.241-12.7138 12.7138c-1.4866 1.4866-1.4866 3.8968 0 5.3834l12.7138 12.7138c1.4865 1.4866 3.8968 1.4866 5.3834 0l12.7138-12.7138c1.4866-1.4866 1.4866-3.8968 0-5.3834l-12.7138-12.7138c-1.4866-1.4866-3.8968-1.4866-5.3834 0z" fill="#f04e98"/><path d="m31.2467 47.473c.0298-.077.0439-.1591.0416-.2416-.0024-.0825-.0212-.1637-.0553-.2388-.0342-.0751-.083-.1427-.1436-.1987s-.1318-.0994-.2094-.1276c-.26-.1266-.5733.0534-.74.42-.0367.055-.0611.1174-.0712.1827-.0101.0654-.0058.1322.0126.1957s.0505.1223.0941.1721c.0435.0498.0974.0894.1579.1162.1634.0358.3334.0288.4934-.0202.1599-.0491.3046-.1385.4199-.2598z" fill="#01ada1"/><path d="m21.22 51.2334c-.0534.1066.12.2533.2.3066.041.017.0868.0182.1286.0034.0418-.0147.0768-.0444.098-.0834.0143-.05.0151-.1029.0022-.1532-.0129-.0504-.039-.0964-.0755-.1334-.0533-.04-.3-.0467-.3533.06z" fill="#01ada1"/><path d="m23.58 46.3801c.12.0533.2467-.1333.2934-.2133.0097-.0448.006-.0914-.0106-.1341s-.0454-.0795-.0828-.1059c-.08 0-.2133.0733-.2733.1466-.06.0734-.04.2534.0733.3067z" fill="#01ada1"/><path d="m26.0334 50.26c-.26-.04-.52-.12-.7734-.18-.0436-.0417-.0964-.0724-.1542-.0896s-.1189-.0204-.1782-.0093c-.0592.011-.115.0361-.1627.073s-.0859.0846-.1115.1392c-.0604.1145-.074.248-.0379.3723.036.1244.1189.2299.2312.2944.1431.0888.2658.2069.36.3466.0428.0932.1047.1763.1816.244.077.0678.1672.1187.2651.1494h.2866c.1352-.0798.249-.1913.3315-.3249.0824-.1335.1311-.2852.1419-.4418-.02-.2467-.0267-.52-.38-.5733z" fill="#01ada1"/><path d="m27.2131 47.5664c-.0424-.0258-.0905-.0408-.1401-.0435s-.0991.007-.144.0281c-.045.0212-.0839.0531-.1135.0931-.0295.0399-.0487.0865-.0557.1357.006.1437.0353.2856.0866.42.0573.0211.1183.0306.1793.0279s.1209-.0175.1761-.0435c.0552-.0261.1047-.0629.1456-.1083.0408-.0454.0722-.0985.0924-.1561.06-.2067-.0734-.2934-.2267-.3534z" fill="#01ada1"/><path d="m32.9259 47.2002c.1079.0564.2337.0677.35.0315.1162-.0363.2133-.1171.27-.2248.0031-.0399.0031-.0801 0-.12.0629-.0213.1199-.0571.1662-.1046.0464-.0475.0808-.1054.1005-.1687.005-.0637-.0051-.1277-.0294-.1868-.0243-.059-.0622-.1115-.1106-.1532-.065-.0291-.1355-.0441-.2067-.0441s-.1416.015-.2066.0441c-.0385.0311-.0702.0696-.0934.1133-.1037-.0148-.2096.0021-.3035.0485-.094.0464-.1718.1201-.2232.2115-.0303.1114-.0177.2301.0354.3326s.1428.1813.2513.2207z" fill="#01ada1"/><path d="m29.386 50.9335c-.1003-.0441-.2137-.0479-.3167-.0106-.103.0372-.1877.1126-.2367.2106-.0428.0734-.0619.1583-.0548.243.0072.0847.0403.1651.0948.2303h.7467c.0304-.0311.0552-.0671.0733-.1066.0174-.0574.0233-.1176.0172-.1772-.006-.0596-.0238-.1174-.0523-.1701s-.0672-.0992-.1138-.1369-.1002-.0657-.1577-.0825z" fill="#01ada1"/><path d="m29.1995 49.0197c.06-.2066-.0733-.2866-.2266-.3533-.1534-.0667-.4334 0-.4534.2133.0048.1439.0341.286.0867.42.0571.0231.1184.0341.18.0324.0616-.0018.1222-.0163.1779-.0426s.1054-.0638.1459-.1103c.0406-.0464.071-.1007.0895-.1595z" fill="#01ada1"/><path d="m27.1864 45.9996c-.0193.0534-.0206.1116-.0038.1657.0169.0542.051.1013.0971.1343.1.0467.24-.0533.28-.12.04-.0666.0467-.3266-.0533-.3666s-.2933.0933-.32.1866z" fill="#01ada1"/><path d="m23.2465 47.5599c-.3733-.04-.48.4467-.3533.6667s.0534.1733-.0933.4133-.22.6667.26.7734c.2238.0329.4519-.0165.642-.1391.19-.1226.3291-.3101.3913-.5276.0311-.2675-.0384-.5371-.1948-.7563-.1565-.2193-.3888-.3727-.6519-.4304z" fill="#01ada1"/><path d="m43.5731 46.7c-.2866-.6666-.8266-.2733-.9733-.4466-.1467-.1734-.18-.6334-.3733-.9134-.0483-.0973-.1176-.1827-.2029-.2499-.0853-.0673-.1845-.1148-.2904-.139s-.2159-.0246-.322-.0012c-.106.0235-.2056.0702-.2914.1368-.0901.0659-.1656.1497-.2216.2462-.0561.0965-.0915.2036-.104.3145-.0126.111-.0019.2233.0312.3299.0331.1065.0879.2051.1611.2894-.0159.023-.0293.0476-.04.0733.0004.178.0548.3516.156.498s.2443.2587.4106.322c.1311.0345.2689.0345.4 0-.0432.0775-.0658.1647-.0658.2534s.0226.1759.0658.2533c.2267.5734.86.7267 1.1134.3667.2533-.36.0733-.6667.1533-.7534.08-.0866.52-.2599.3933-.58z" fill="#01ada1"/><path d="m45.0998 47.4797c-.0596-.0098-.1204-.0098-.18 0-.0141-.0845-.0506-.1637-.1057-.2293-.0551-.0657-.1268-.1154-.2076-.144-.161-.0145-.3222.0256-.4577.1139-.1354.0883-.2372.2196-.289.3728-.0147.0535-.018.1096-.0095.1645s.0285.1074.0587.154c.0303.0466.07.0863.1167.1164.0467.0302.0992.0501.1541.0584.1001.03.2067.03.3067 0-.0153.0946-.0048.1916.0304.2808s.0938.1672.1696.2259c.4267.2333.7867-.1267.9667-.32s-.1467-.7067-.5534-.7934z" fill="#01ada1"/><path d="m45.7068 44.6205c-.0669-.1356-.1779-.2444-.3148-.3086s-.2915-.08-.4386-.0447c-.12 0-.2333.04-.36.06l-.12-.1534h-1.7c0 .04.06.08.0934.1134.0322.0896.0796.173.14.2466-.1334.46.1333.7067.5.9067-.0934.24-.32.4667 0 .7067.0765.067.1749.104.2766.104.1018 0 .2001-.037.2767-.104.3-.2267.1267-.4867 0-.74l.3533-.3267.4467.44c.1645-.0297.3253-.0767.48-.14.0923-.0574.1681-.1378.22-.2333.0864-.0604.1572-.1404.2067-.2334.0165-.0498.0197-.1032.0092-.1547-.0106-.0514-.0344-.0992-.0692-.1386z" fill="#01ada1"/><path d="m34.7133 46.9728c.0647-.0417.1178-.0989.1547-.1665.0368-.0675.0561-.1432.0561-.2202 0-.0769-.0193-.1526-.0561-.2202-.0369-.0675-.09-.1247-.1547-.1664-.0852-.0413-.1787-.0628-.2734-.0628-.0946 0-.1881.0215-.2733.0628-.0662.053-.1184.1214-.1521.1993-.0337.0778-.0479.1628-.0412.2473.0223.0735.0602.1414.1111.1989s.1136.1033.1839.1343c.0703.0311.1464.0465.2232.0454s.1525-.0188.2218-.0519z" fill="#01ada1"/><path d="m40.98 44.7672c.0867-.14-.0533-.4867-.24-.5934h-.22c-.0765.0331-.1414.0881-.1865.1582-.0451.07-.0684.1519-.0668.2352.04.2466.6066.3666.7133.2z" fill="#01ada1"/><path d="m38.2197 47.5797c-.1266.36 0 .6666.2467.7666.1953.0463.4003.0269.5835-.055.1831-.0819.3342-.2219.4298-.3983.0076-.1454-.0326-.2892-.1144-.4096-.0819-.1204-.2009-.2107-.3389-.2571-.0752-.0375-.1577-.058-.2417-.0601s-.1674.0143-.2443.048c-.077.0337-.1456.0839-.201.1471-.0554.0631-.0963.1377-.1197.2184z" fill="#01ada1"/><path d="m35.4 44.1738h-.5533c.0466.1334.1.24.16.2667s.3266-.0667.3933-.2667z" fill="#01ada1"/><path d="m38.5202 47.0069c.44-.5667.7067-.1333 1.1533-.4333.0621-.0542.116-.1171.16-.1867.0628-.091.0982-.198.1022-.3084.004-.1105-.0236-.2198-.0797-.315-.056-.0953-.1381-.1726-.2366-.2227-.0984-.0502-.2092-.0712-.3192-.0606-.3233.0076-.644.0592-.9533.1534-.1713.0841-.3114.2205-.4001.3895-.0886.169-.1213.3617-.0933.5505.0334.2066.2734.6066.6667.4333z" fill="#01ada1"/><path d="m39.4998 45.3331c.0448.0226.0943.0344.1445.0344.0502-.0001.0997-.0119.1445-.0347.0448-.0227.0836-.0556.1133-.0961s.0494-.0874.0577-.137c.0124-.0778.0093-.1573-.009-.2339s-.0515-.1489-.0977-.2127c-.0933-.0867-.5333-.0467-.6067.14-.0367.1052-.0307.2207.0167.3216.0473.1009.1323.1793.2367.2184z" fill="#01ada1"/><path d="m31.5731 45.8396c.26-.08.2933-.2933.18-.7866-.2534-.0467-.54-.1934-.7267.14-.0594.0944-.08.2081-.0576.3174.0223.1092.0859.2057.1776.2692.0623.0408.1329.0673.2066.0777.0738.0103.1489.0043.2201-.0177z" fill="#01ada1"/><path d="m48.153 50.2532c-.0553-.0249-.1152-.0383-.1759-.0393s-.121.0104-.1771.0335c-.0562.0231-.107.0574-.1495.1008s-.0756.095-.0975.1517c-.0171.0524-.0233.1079-.0179.1629.0053.055.0219.1083.0489.1565.0269.0482.0635.0903.1075.1237.0441.0334.0945.0573.1482.0702.1158.0379.2419.0283.3507-.0267.1087-.055.1912-.1509.2293-.2666.0161-.0482.0219-.0992.017-.1497-.005-.0505-.0206-.0994-.0457-.1434-.0252-.0441-.0594-.0824-.1004-.1123s-.0879-.0508-.1376-.0613z" fill="#01ada1"/><path d="m45.0527 49.6804c-.033-.0068-.067-.0068-.1 0-.0908-.206-.2582-.3686-.4667-.4534-.1719-.0213-.3456.0213-.4881.1196-.1426.0983-.2441.2456-.2852.4138-.01.0529-.01.1071 0 .16-.068.1786-.0997.369-.0934.56.0467.1267.2867.4.0667.5533-.22.1534-.56.14-.6667.4067l-.04.0933c-.0478-.0482-.1076-.0827-.1733-.1-.1752-.0173-.3502.0355-.4867.1467h1.9134c-.0299-.1069-.0299-.2198 0-.3267.0733-.1533.2133-.3.4333-.1666.22.1333.62.0533.8667-.42.2466-.4734-.0934-1.0401-.48-.9867z" fill="#01ada1"/><path d="m47.1528 50.7266c-.2266-.06-.58.2334-.6266.52-.0151.0693-.0077.1415.0211.2062s.0774.1186.1389.1538h.8733c.0358-.0345.0612-.0784.0733-.1266.013-.1607-.0268-.3213-.1135-.4573-.0866-.136-.2153-.2399-.3665-.2961z" fill="#01ada1"/><path d="m45.46 51.6064h1.1333c-.0941-.0882-.2053-.1563-.3267-.2-.1372-.063-.2917-.0774-.4383-.0411-.1465.0363-.2764.1213-.3683.2411z" fill="#01ada1"/><path d="m47.7127 49.3931c.12-.4533-.1133-.7866-.6666-.9133-.1193-.0186-.2407-.0186-.36 0-.0881-.007-.1765.0104-.2553.0504s-.145.101-.1914.1763c-.0799.1005-.1389.2162-.1733.34-.0189.1118-.0143.2263.0134.3363s.0779.2131.1474.3027c.0696.0896.157.1638.2566.2179.0997.0541.2096.0869.3226.0964.1988.0247.3999-.023.5664-.1344s.2873-.2791.3402-.4723z" fill="#01ada1"/><path d="m51.6133 49.7136c.0393.0088.0802.0086.1194-.0006.0393-.0093.076-.0273.1072-.0527l-1.0733-1.0734c-.117.1033-.2103.2306-.2733.3734-.0376.0771-.0582.1613-.0605.2471-.0022.0857.0139.171.0474.25.0335.0789.0835.1498.1467.2078s.1382.1018.2197.1284c.122.0615.2592.0866.3951.0725.1359-.0142.2649-.0671.3716-.1525z" fill="#01ada1"/><path d="m49.0793 51.1938c-.1123.0039-.2196.0476-.3027.1233-.083.0757-.1364.1786-.1506.2901h.8933c.0089-.0588.0034-.1188-.016-.1749s-.0522-.1067-.0955-.1473c-.0433-.0407-.0958-.0702-.153-.0861s-.1174-.0176-.1755-.0051z" fill="#01ada1"/><path d="m52.573 50.4c-.4-.0534-.8933.0333-1.04.2666-.025.0326-.039.0723-.04.1134.1134.38-.1866.52-.3933.7266-.0287.03-.0533.0637-.0733.1h1.2466c.0303-.0509.0708-.095.119-.1295.0482-.0344.103-.0584.161-.0705.0667.06.1334.1467.2.2h.58c.0388-.0103.0767-.0237.1134-.04.0336-.0254.0604-.0589.0779-.0973.0174-.0384.025-.0806.0221-.1227z" fill="#01ada1"/><path d="m49.1794 47.4132c-.0775-.0394-.1607-.0664-.2466-.08.0728.0122.1471.0122.22 0 .0832-.02.1607-.0587.2266-.1133l-1.5333-1.5334c-.0867.1867-.16.3467-.2333.36-.0734.0134-.6667-.4866-1.1867-.28-.2215.0959-.3976.2732-.492.4954-.0943.2221-.0996.472-.0147.698.1427.2641.3577.4822.6198.6286s.5605.2151.8602.198c.4267-.1133.44-.6666.56-.7133s.4734.2533.8867.2867c-.1399.0015-.2766.0422-.3945.1177-.1179.0754-.2122.1825-.2722.3089-.0806.1702-.1062.3613-.0733.5467-.0522.0724-.0906.1537-.1133.24-.0258.1295-.025.2628.0023.392.0274.1291.0807.2513.1568.3592s.1733.1992.2858.2683c.1124.0691.2378.1147.3684.1338.1222.0306.2493.0362.3738.0167.1244-.0196.2437-.0639.3506-.1305.107-.0665.1995-.1539.272-.2569.0726-.103.1237-.2195.1503-.3426.0358-.1117.0448-.2303.0263-.3461s-.0641-.2256-.133-.3206c0-.2056-.0642-.4061-.1838-.5735-.1195-.1673-.2883-.2931-.4829-.3598z" fill="#01ada1"/><path d="m42.1392 50c-.0031-.0131-.0031-.0268 0-.04.093-.0206.1781-.0673.2454-.1346s.114-.1525.1346-.2454c.0093-.121-.0297-.2407-.1084-.3331-.0786-.0925-.1906-.15-.3116-.1602-.0804-.0239-.1661-.0239-.2466 0-.1706-.0495-.3524-.0431-.5191.018-.1667.0612-.3095.174-.4076.322-.096-.0666-.2051-.112-.32-.1334-.0627-.0276-.1304-.042-.199-.042-.0685-.0001-.1363.0142-.199.0418s-.119.0679-.1652.1185c-.0463.0505-.0815.1102-.1035.1751-.0134.1776.0232.3555.1057.5134.0825.1578.2075.2895.361.3799.0736.0145.1493.0142.2228-.0008s.1432-.0444.2052-.0866.1151-.0962.156-.1591c.041-.0628.0691-.1331.0827-.2068.0663.0586.1403.108.22.1466.0661.0467.1412.0793.2204.0958.0793.0166.161.0167.2404.0005.0793-.0162.1544-.0485.2208-.0949.0663-.0464.1225-.1058.165-.1747z" fill="#01ada1"/><path d="m36.0801 51.6061h.7c-.0587-.0589-.1262-.1084-.2-.1467-.086-.0455-.1864-.0557-.2798-.0283s-.1724.0902-.2202.175z" fill="#01ada1"/><path d="m36.4398 49.227c.1134-.2934 0-.7067-.2133-.7934-.1983-.0593-.4113-.0451-.6.04-.0633-.0856-.1558-.1449-.26-.1666-.1022-.0191-.2079-.004-.3007.043s-.1675.1232-.2126.217c-.2677-.119-.5633-.1606-.8534-.12-.126.013-.2532-.0101-.3666-.0667-.0747-.069-.1661-.1174-.2651-.1404-.099-.0231-.2023-.02-.2998.0089-.0974.0289-.1857.0827-.2562.156-.0704.0733-.1206.1636-.1456.2622-.0373-.0448-.0855-.0793-.14-.1-.122-.011-.2441.021-.3451.0904-.101.0693-.1746.1717-.2082.2896-.0623.1282-.0713.2759-.0251.4108s.1439.246.2717.3092c.0578.0184.1187.025.179.0195.0604-.0056.119-.0231.1725-.0517.0534-.0286.1006-.0676.1387-.1148.0381-.0471.0664-.1014.0832-.1597.1199.096.262.1602.4133.1867.219.0113.4258.1041.58.26.1172.1107.2611.1891.4177.2277.1565.0385.3204.0359.4756-.0077.1581-.0367.3016-.1197.412-.2385.1105-.1188.183-.2679.208-.4282 0 0 0-.0533 0-.0867.0771.1836.2231.3296.4067.4067.2933.1133.5733-.0733.7333-.4533z" fill="#01ada1"/><path d="m37.6666 44.6667c-.0513-.0986-.1298-.1803-.2262-.2355s-.2066-.0816-.3176-.076c-.1109.0057-.2179.043-.3082.1077-.0904.0646-.1602.1539-.2013.2571-.1173.2395-.2046.4925-.26.7533-.0039-.0233-.015-.0449-.0317-.0616-.0167-.0168-.0383-.0278-.0617-.0317-.034-.0022-.0677.008-.0947.0289s-.0455.0509-.0519.0844c0 .04.0733.12.1067.1267.0364.0023.0722-.0096.0999-.0333.0022.0154.0022.0311 0 .0466-.0031.1916.0537.3794.1625.5372.1087.1577.264.2776.4442.3428.0827.0408.1779.0484.2659.0211.0881-.0273.1623-.0873.2074-.1677.2692-.4424.3971-.9564.3667-1.4734-.0239-.0794-.0574-.1555-.1-.2266z" fill="#01ada1"/><path d="m35.6132 51.2064c-.0625-.0258-.1295-.0389-.1971-.0384-.0677.0005-.1345.0145-.1966.0412-.0622.0267-.1183.0656-.1652.1143-.0469.0488-.0835.1065-.1077.1696-.0069.0375-.0069.0759 0 .1133h.94c-.0031-.0857-.0304-.1687-.0788-.2395s-.1158-.1265-.1946-.1605z" fill="#01ada1"/><path d="m39.0461 49.8604c-.1643-.0983-.358-.1354-.547-.1049s-.3611.1267-.4862.2717c-.125.145-.1949.3294-.1974.5208s.0627.3776.184.5257c-.0454.0515-.0774.1133-.0934.18-.0407.1143-.0407.2391 0 .3533h.76v-.04c.0133-.0637.0133-.1295 0-.1933.1451-.0305.2811-.0946.3969-.1872.1158-.0927.2082-.2112.2698-.3461.0824-.1698.0987-.3642.0457-.5453-.0529-.1812-.1714-.3361-.3324-.4347z" fill="#01ada1"/><path d="m42.4527 51.3731c.08-.1867-.06-.4-.1667-.5334-.1066-.1333-.3866 0-.4733.16s0 .42.1533.4667c.1534.0467.4067.0867.4867-.0933z" fill="#01ada1"/><path d="m31.3863 50.8071c-.3533-.1333-.7466.0001-.84.2201-.041.1998-.0027.4078.1067.58h1.1267c.0359-.0776.054-.1622.053-.2476-.001-.0855-.0211-.1696-.0588-.2463s-.0921-.144-.1591-.197c-.0671-.0529-.1451-.0902-.2285-.1092z" fill="#01ada1"/><path d="m39.9131 51.6068h.5333c-.0597-.0442-.1277-.076-.2-.0934-.059-.0099-.1195-.0067-.1771.0095-.0576.0161-.1109.0448-.1562.0839z" fill="#01ada1"/><path d="m39.9999 48.827c-.0166-.0695-.0494-.1342-.0958-.1887-.0463-.0544-.1049-.0971-.1709-.1246-.1133 0-.4333 0-.4867.1466-.0533.1467.2.3667.3134.4334.1133.0666.44-.08.44-.2667z" fill="#01ada1"/><path d="m58.5392 39.7h-3.66l-1.22-11.3934h6.1z" fill="#fff"/><path d="m58.946 44.7835c0-1.2352-1.0014-2.2366-2.2367-2.2366s-2.2366 1.0014-2.2366 2.2366c0 1.2353 1.0013 2.2367 2.2366 2.2367s2.2367-1.0014 2.2367-2.2367z" fill="#fff"/></svg> \ No newline at end of file diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/related_alerts.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/related_alerts.tsx index 944481e60fbe5..6ccebc34e4722 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/related_alerts.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/related_alerts.tsx @@ -6,13 +6,34 @@ */ import React, { useState, useRef, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { getPaddedAlertTimeRange } from '@kbn/observability-get-padded-alert-time-range-util'; -import { ALERT_END, ALERT_START, ALERT_UUID } from '@kbn/rule-data-utils'; +import { + ALERT_END, + ALERT_GROUP, + ALERT_RULE_UUID, + ALERT_START, + ALERT_UUID, + TAGS, +} from '@kbn/rule-data-utils'; import { BoolQuery, Filter, type Query } from '@kbn/es-query'; import { AlertsGrouping } from '@kbn/alerts-grouping'; +import { ObservabilityFields } from '../../../../common/utils/alerting/types'; import { observabilityAlertFeatureIds } from '../../../../common/constants'; +import { + getRelatedAlertKuery, + getSharedFields, +} from '../../../../common/utils/alerting/get_related_alerts_query'; import { TopAlert } from '../../..'; import { AlertSearchBarContainerState, @@ -30,25 +51,25 @@ import { Provider, useAlertSearchBarStateContainer, } from '../../../components/alert_search_bar/containers'; -import { ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID, SEARCH_BAR_URL_STORAGE_KEY } from '../../../constants'; +import { RELATED_ALERTS_TABLE_CONFIG_ID, SEARCH_BAR_URL_STORAGE_KEY } from '../../../constants'; import { usePluginContext } from '../../../hooks/use_plugin_context'; import { useKibana } from '../../../utils/kibana_react'; import { buildEsQuery } from '../../../utils/build_es_query'; import { mergeBoolQueries } from '../../alerts/helpers/merge_bool_queries'; +import icon from './assets/illustration_product_no_results_magnifying_glass.svg'; const ALERTS_PER_PAGE = 50; const RELATED_ALERTS_SEARCH_BAR_ID = 'related-alerts-search-bar-o11y'; const ALERTS_TABLE_ID = 'xpack.observability.related.alerts.table'; interface Props { - alert: TopAlert; - kuery: string; + alert?: TopAlert<ObservabilityFields>; } const defaultState: AlertSearchBarContainerState = { ...DEFAULT_STATE, status: 'active' }; const DEFAULT_FILTERS: Filter[] = []; -export function InternalRelatedAlerts({ alert, kuery }: Props) { +export function InternalRelatedAlerts({ alert }: Props) { const kibanaServices = useKibana().services; const { http, @@ -62,9 +83,14 @@ export function InternalRelatedAlerts({ alert, kuery }: Props) { }); const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(); - const alertStart = alert.fields[ALERT_START]; - const alertEnd = alert.fields[ALERT_END]; - const alertId = alert.fields[ALERT_UUID]; + const alertStart = alert?.fields[ALERT_START]; + const alertEnd = alert?.fields[ALERT_END]; + const alertId = alert?.fields[ALERT_UUID]; + const tags = alert?.fields[TAGS]; + const groups = alert?.fields[ALERT_GROUP]; + const ruleId = alert?.fields[ALERT_RULE_UUID]; + const sharedFields = getSharedFields(alert?.fields); + const kuery = getRelatedAlertKuery({ tags, groups, ruleId, sharedFields }); const defaultQuery = useRef<Query[]>([ { query: `not kibana.alert.uuid: ${alertId}`, language: 'kuery' }, @@ -79,6 +105,8 @@ export function InternalRelatedAlerts({ alert, kuery }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [alertStart, alertEnd]); + if (!kuery || !alert) return <EmptyState />; + return ( <EuiFlexGroup direction="column" gutterSize="m"> <EuiFlexItem> @@ -103,7 +131,7 @@ export function InternalRelatedAlerts({ alert, kuery }: Props) { to={alertSearchBarStateProps.rangeTo} globalFilters={alertSearchBarStateProps.filters ?? DEFAULT_FILTERS} globalQuery={{ query: alertSearchBarStateProps.kuery, language: 'kuery' }} - groupingId={ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID} + groupingId={RELATED_ALERTS_TABLE_CONFIG_ID} defaultGroupingOptions={DEFAULT_GROUPING_OPTIONS} getAggregationsByGroupingField={getAggregationsByGroupingField} renderGroupPanel={renderGroupPanel} @@ -122,7 +150,7 @@ export function InternalRelatedAlerts({ alert, kuery }: Props) { <AlertsStateTable id={ALERTS_TABLE_ID} featureIds={observabilityAlertFeatureIds} - configurationId={ALERTS_PAGE_ALERTS_TABLE_CONFIG_ID} + configurationId={RELATED_ALERTS_TABLE_CONFIG_ID} query={mergeBoolQueries(esQuery, groupQuery)} showAlertStatusWithFlapping initialPageSize={ALERTS_PER_PAGE} @@ -138,6 +166,50 @@ export function InternalRelatedAlerts({ alert, kuery }: Props) { ); } +const heights = { + tall: 490, + short: 250, +}; +const panelStyle = { + maxWidth: 500, +}; + +function EmptyState() { + return ( + <EuiPanel color="subdued" data-test-subj="relatedAlertsTabEmptyState"> + <EuiFlexGroup style={{ height: heights.tall }} alignItems="center" justifyContent="center"> + <EuiFlexItem grow={false}> + <EuiPanel hasBorder={true} style={panelStyle}> + <EuiFlexGroup> + <EuiFlexItem> + <EuiText size="s"> + <EuiTitle> + <h3> + <FormattedMessage + id="xpack.observability.pages.alertDetails.relatedAlerts.empty.title" + defaultMessage="Problem loading related alerts" + /> + </h3> + </EuiTitle> + <p> + <FormattedMessage + id="xpack.observability.pages.alertDetails.relatedAlerts.empty.description" + defaultMessage="Due to an unexpected error, no related alerts can be found." + /> + </p> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiImage style={{ width: 200, height: 148 }} size="200" alt="" url={icon} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); +} + export function RelatedAlerts(props: Props) { return ( <Provider value={alertSearchBarStateContainer}> From da63469091f9b76e9e5d053cf11a4df15e14fdef Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" <christiane.heiligers@elastic.co> Date: Tue, 15 Oct 2024 14:14:56 -0700 Subject: [PATCH 14/31] Update cli-description (#196208) Updates the `cli` description text to reflect license change from https://github.com/elastic/kibana/pull/192025. --- src/cli/cli.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/cli.js b/src/cli/cli.js index f87cc6b5c443e..7ca1f5a694615 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -19,8 +19,7 @@ const program = new Command('bin/kibana'); program .version(pkg.version) .description( - 'Kibana is an open and free, browser ' + - 'based analytics and search dashboard for Elasticsearch.' + 'Kibana is an open source, browser based analytics and search dashboard for Elasticsearch.' ); // attach commands From b67bd83ea93909d809206b1004c306a11fd8ee3f Mon Sep 17 00:00:00 2001 From: Ryland Herrick <ryalnd@gmail.com> Date: Tue, 15 Oct 2024 16:26:25 -0500 Subject: [PATCH 15/31] [Security Solution] Allow exporting of prebuilt rules via the API (#194498) ## Summary This PR introduces the backend functionality necessary to export prebuilt rules via our existing export APIs: 1. Export Rules - POST /rules/_export 2. Bulk Actions - POST /rules/_bulk_action The [Prebuilt Rule Customization RFC](https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/docs/rfcs/detection_response/prebuilt_rules_customization.md) goes into detail, and the export-specific issue is described [here](https://github.com/elastic/kibana/issues/180167#issue-2227974379). ## Steps to Review 1. Enable the Feature Flag: `prebuiltRulesCustomizationEnabled` 1. Install the prebuilt rules package via fleet 1. Install some prebuilt rules, and obtain a prebuilt rule's `rule_id`, e.g. `ac8805f6-1e08-406c-962e-3937057fa86f` 1. Export the rule via the export route, e.g. (in Dev Tools): POST kbn:api/detection_engine/rules/_export Note that you may need to use the CURL equivalent for these requests, as the dev console does not seem to handle file responses: curl --location --request POST 'http://localhost:5601/api/detection_engine/rules/_export?exclude_export_details=true&file_name=exported_rules.ndjson' \ --header 'kbn-xsrf: true' \ --header 'elastic-api-version: 2023-10-31' \ --header 'Authorization: Basic waefoijawoefiajweo==' 1. Export the rule via bulk actions, e.g. (in Dev Tools): POST kbn:api/detection_engine/rules/_bulk_action { "action": "export" } 1. Observe that the exported rules' fields are correct, especially `rule_source` and `immutable` (see tests added here for examples). ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../api/rules/bulk_actions/route.ts | 3 +- .../api/rules/export_rules/route.ts | 30 +- .../logic/export/get_export_all.ts | 10 +- .../logic/export/get_export_by_object_ids.ts | 15 +- .../trial_license_complete_tier/index.ts | 1 + .../rules_export.ts | 335 ++++++++++++++++++ 6 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/rules_export.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 4d31bd457a3e9..658a9b193e0a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -281,7 +281,8 @@ export const performBulkActionRoute = ( rules.map(({ params }) => params.ruleId), exporter, request, - actionsClient + actionsClient, + config.experimentalFeatures.prebuiltRulesCustomizationEnabled ); const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.actionConnectors}${exported.exportDetails}`; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts index 478a0ce02cc96..3c770c714334c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts @@ -15,7 +15,10 @@ import { } from '../../../../../../../common/api/detection_engine/rule_management'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; import type { ConfigType } from '../../../../../../config'; -import { getNonPackagedRulesCount } from '../../../logic/search/get_existing_prepackaged_rules'; +import { + getNonPackagedRulesCount, + getRulesCount, +} from '../../../logic/search/get_existing_prepackaged_rules'; import { getExportByObjectIds } from '../../../logic/export/get_export_by_object_ids'; import { getExportAll } from '../../../logic/export/get_export_all'; import { buildSiemResponse } from '../../../../routes/utils'; @@ -57,6 +60,8 @@ export const exportRulesRoute = ( const client = getClient({ includedHiddenTypes: ['action'] }); const actionsExporter = getExporter(client); + const { prebuiltRulesCustomizationEnabled } = config.experimentalFeatures; + try { const exportSizeLimit = config.maxRuleImportExportSize; if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { @@ -65,10 +70,19 @@ export const exportRulesRoute = ( body: `Can't export more than ${exportSizeLimit} rules`, }); } else { - const nonPackagedRulesCount = await getNonPackagedRulesCount({ - rulesClient, - }); - if (nonPackagedRulesCount > exportSizeLimit) { + let rulesCount = 0; + + if (prebuiltRulesCustomizationEnabled) { + rulesCount = await getRulesCount({ + rulesClient, + filter: '', + }); + } else { + rulesCount = await getNonPackagedRulesCount({ + rulesClient, + }); + } + if (rulesCount > exportSizeLimit) { return siemResponse.error({ statusCode: 400, body: `Can't export more than ${exportSizeLimit} rules`, @@ -84,14 +98,16 @@ export const exportRulesRoute = ( request.body.objects.map((obj) => obj.rule_id), actionsExporter, request, - actionsClient + actionsClient, + prebuiltRulesCustomizationEnabled ) : await getExportAll( rulesClient, exceptionsClient, actionsExporter, request, - actionsClient + actionsClient, + prebuiltRulesCustomizationEnabled ); const responseBody = request.query.exclude_export_details diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts index cdf8c6333e595..4407a15622cd6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts @@ -11,7 +11,7 @@ import type { ISavedObjectsExporter, KibanaRequest } from '@kbn/core/server'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import type { RulesClient } from '@kbn/alerting-plugin/server'; import type { ActionsClient } from '@kbn/actions-plugin/server'; -import { getNonPackagedRules } from '../search/get_existing_prepackaged_rules'; +import { getNonPackagedRules, getRules } from '../search/get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../../utils/utils'; import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; @@ -23,14 +23,18 @@ export const getExportAll = async ( exceptionsClient: ExceptionListClient | undefined, actionsExporter: ISavedObjectsExporter, request: KibanaRequest, - actionsClient: ActionsClient + actionsClient: ActionsClient, + prebuiltRulesCustomizationEnabled?: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; actionConnectors: string; + prebuiltRulesCustomizationEnabled?: boolean; }> => { - const ruleAlertTypes = await getNonPackagedRules({ rulesClient }); + const ruleAlertTypes = prebuiltRulesCustomizationEnabled + ? await getRules({ rulesClient, filter: '' }) + : await getNonPackagedRules({ rulesClient }); const rules = transformAlertsToRules(ruleAlertTypes); const exportRules = rules.map((r) => transformRuleToExportableFormat(r)); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts index 7c3142aed85f6..02355d39e7e6d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts @@ -29,15 +29,21 @@ export const getExportByObjectIds = async ( ruleIds: string[], actionsExporter: ISavedObjectsExporter, request: KibanaRequest, - actionsClient: ActionsClient + actionsClient: ActionsClient, + prebuiltRulesCustomizationEnabled?: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; actionConnectors: string; + prebuiltRulesCustomizationEnabled?: boolean; }> => withSecuritySpan('getExportByObjectIds', async () => { - const rulesAndErrors = await fetchRulesByIds(rulesClient, ruleIds); + const rulesAndErrors = await fetchRulesByIds( + rulesClient, + ruleIds, + prebuiltRulesCustomizationEnabled + ); const { rules, missingRuleIds } = rulesAndErrors; // Retrieve exceptions @@ -76,7 +82,8 @@ interface FetchRulesResult { const fetchRulesByIds = async ( rulesClient: RulesClient, - ruleIds: string[] + ruleIds: string[], + prebuiltRulesCustomizationEnabled?: boolean ): Promise<FetchRulesResult> => { // It's important to avoid too many clauses in the request otherwise ES will fail to process the request // with `too_many_clauses` error (see https://github.com/elastic/kibana/issues/170015). The clauses limit @@ -110,7 +117,7 @@ const fetchRulesByIds = async ( return matchingRule != null && hasValidRuleType(matchingRule) && - matchingRule.params.immutable !== true + (prebuiltRulesCustomizationEnabled || matchingRule.params.immutable !== true) ? { rule: transformRuleToExportableFormat(internalRuleToAPIResponse(matchingRule)), } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/index.ts index 76a461d438463..4324ce4602d72 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext): void => { describe('Rules Management - Prebuilt Rules - Update Prebuilt Rules Package', function () { loadTestFile(require.resolve('./is_customized_calculation')); + loadTestFile(require.resolve('./rules_export')); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/rules_export.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/rules_export.ts new file mode 100644 index 0000000000000..e49a23f6138a3 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/prebuilt_rule_customization/trial_license_complete_tier/rules_export.ts @@ -0,0 +1,335 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; + +import { + BulkActionEditTypeEnum, + BulkActionTypeEnum, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; +import { + binaryToString, + createPrebuiltRuleAssetSavedObjects, + createRuleAssetSavedObject, + deleteAllPrebuiltRuleAssets, + getCustomQueryRuleParams, + installPrebuiltRules, +} from '../../../../utils'; + +const parseNdJson = (ndJson: Buffer): unknown[] => + ndJson + .toString() + .split('\n') + .filter((line) => !!line) + .map((line) => JSON.parse(line)); + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); + const log = getService('log'); + + /** + * This test suite is skipped in Serverless MKI environments due to reliance on the + * feature flag for prebuilt rule customization. + */ + describe('@ess @serverless @skipInServerlessMKI Exporting Rules with Prebuilt Rule Customization', () => { + beforeEach(async () => { + await deleteAllPrebuiltRuleAssets(es, log); + await deleteAllRules(supertest, log); + }); + + it('exports a set of custom installed rules via the _export API', async () => { + await securitySolutionApi + .bulkCreateRules({ + body: [ + getCustomQueryRuleParams({ rule_id: 'rule-id-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-id-2' }), + ], + }) + .expect(200); + + const { body: exportResult } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + const ndJson = parseNdJson(exportResult); + + expect(ndJson).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-id-1', + rule_source: { + type: 'internal', + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-2', + rule_source: { + type: 'internal', + }, + }), + ]) + ); + }); + + describe('with prebuilt rules installed', () => { + let ruleAssets: Array<ReturnType<typeof createRuleAssetSavedObject>>; + + beforeEach(async () => { + ruleAssets = [ + createRuleAssetSavedObject({ + rule_id: '000047bb-b27a-47ec-8b62-ef1a5d2c9e19', + tags: ['test-tag'], + }), + createRuleAssetSavedObject({ + rule_id: '60b88c41-c45d-454d-945c-5809734dfb34', + tags: ['test-tag-2'], + }), + ]; + await createPrebuiltRuleAssetSavedObjects(es, ruleAssets); + await installPrebuiltRules(es, supertest); + }); + + it('exports a set of prebuilt installed rules via the _export API', async () => { + const { body: exportResult } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + const parsedExportResult = parseNdJson(exportResult); + + expect(parsedExportResult).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: ruleAssets[0]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: ruleAssets[1]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + ]) + ); + + const [firstExportedRule, secondExportedRule] = parsedExportResult as Array<{ + id: string; + rule_id: string; + }>; + + const { body: bulkEditResult } = await securitySolutionApi + .performRulesBulkAction({ + query: {}, + body: { + ids: [firstExportedRule.id], + action: BulkActionTypeEnum.edit, + [BulkActionTypeEnum.edit]: [ + { + type: BulkActionEditTypeEnum.add_tags, + value: ['new-tag'], + }, + ], + }, + }) + .expect(200); + + expect(bulkEditResult.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 1, + total: 1, + }); + expect(bulkEditResult.attributes.results.updated[0].rule_source.is_customized).toEqual( + true + ); + + const { body: secondExportResult } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + expect(parseNdJson(secondExportResult)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: firstExportedRule.rule_id, + rule_source: { + type: 'external', + is_customized: true, + }, + }), + expect.objectContaining({ + rule_id: secondExportedRule.rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + ]) + ); + }); + + it('exports a set of custom and prebuilt installed rules via the _export API', async () => { + await securitySolutionApi + .bulkCreateRules({ + body: [ + getCustomQueryRuleParams({ rule_id: 'rule-id-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-id-2' }), + ], + }) + .expect(200); + + const { body: exportResult } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + const exportJson = parseNdJson(exportResult); + expect(exportJson).toHaveLength(5); // 2 prebuilt rules + 2 custom rules + 1 stats object + + expect(exportJson).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: ruleAssets[0]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: ruleAssets[1]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-1', + rule_source: { + type: 'internal', + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-2', + rule_source: { + type: 'internal', + }, + }), + ]) + ); + }); + + it('exports both custom and prebuilt rules when rule_ids are specified via the _export API', async () => { + await securitySolutionApi + .bulkCreateRules({ + body: [ + getCustomQueryRuleParams({ rule_id: 'rule-id-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-id-2' }), + ], + }) + .expect(200); + + const { body: exportResult } = await securitySolutionApi + .exportRules({ + query: {}, + body: { + objects: [ + { rule_id: ruleAssets[1]['security-rule'].rule_id }, + { rule_id: 'rule-id-2' }, + ], + }, + }) + .expect(200) + .parse(binaryToString); + + const exportJson = parseNdJson(exportResult); + expect(exportJson).toHaveLength(3); // 1 prebuilt rule + 1 custom rule + 1 stats object + + expect(exportJson).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: ruleAssets[1]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-2', + rule_source: { + type: 'internal', + }, + }), + ]) + ); + }); + + it('exports a set of custom and prebuilt installed rules via the bulk_actions API', async () => { + await securitySolutionApi + .bulkCreateRules({ + body: [ + getCustomQueryRuleParams({ rule_id: 'rule-id-1' }), + getCustomQueryRuleParams({ rule_id: 'rule-id-2' }), + ], + }) + .expect(200); + + const { body: exportResult } = await securitySolutionApi + .performRulesBulkAction({ + body: { query: '', action: BulkActionTypeEnum.export }, + query: {}, + }) + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') + .parse(binaryToString); + + const exportJson = parseNdJson(exportResult); + expect(exportJson).toHaveLength(5); // 2 prebuilt rules + 2 custom rules + 1 stats object + + expect(exportJson).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: ruleAssets[0]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: ruleAssets[1]['security-rule'].rule_id, + rule_source: { + type: 'external', + is_customized: false, + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-1', + rule_source: { + type: 'internal', + }, + }), + expect.objectContaining({ + rule_id: 'rule-id-2', + rule_source: { + type: 'internal', + }, + }), + ]) + ); + }); + }); + }); +}; From e4762201fdd84f372c78bc2a159061e504b26e78 Mon Sep 17 00:00:00 2001 From: Marta Bondyra <4283304+mbondyra@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:53:00 +0200 Subject: [PATCH 16/31] [Lens] Fix switchVisualizationType to use it without layerId (#196295) With [PR #187475](https://github.com/elastic/kibana/pull/187475/files) we introduced a bug, affecting the AI assistant's suggestions API when switching between different chart types (e.g., from bar to line chart). This feature relies on the switchVisualizationType method, which was changed to require a `layerId`. However, the suggestions API does not provide `layerId`, causing the chart type to not switch as expected. Solution: The bug can be resolved by modifying the logic in the `switchVisualizationType` method. We changed: ```ts const dataLayer = state.layers.find((l) => l.layerId === layerId); ``` to: ```ts const dataLayer = state.layers.find((l) => l.layerId === layerId) ?? state.layers[0]; ``` This ensures that the suggestions API falls back to the first layer if no specific layerId is provided. --------- Co-authored-by: Marco Vettorello <vettorello.marco@gmail.com> --- .../visualizations/xy/visualization.test.tsx | 26 +++++++++++++++++++ .../visualizations/xy/visualization.tsx | 5 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.tsx index 012823831b8eb..e2c6ce25bd2e3 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.tsx @@ -4214,4 +4214,30 @@ describe('xy_visualization', () => { `); }); }); + describe('switchVisualizationType', () => { + it('should switch all the layers to the new visualization type if layerId is not specified (AI assistant case)', () => { + const state = exampleState(); + state.layers[1] = state.layers[0]; + state.layers[1].layerId = 'second'; + state.layers[2] = state.layers[0]; + state.layers[2].layerId = 'third'; + const newType = 'bar'; + const newState = xyVisualization.switchVisualizationType!(newType, state); + expect((newState.layers[0] as XYDataLayerConfig).seriesType).toEqual(newType); + expect((newState.layers[1] as XYDataLayerConfig).seriesType).toEqual(newType); + expect((newState.layers[2] as XYDataLayerConfig).seriesType).toEqual(newType); + }); + it('should switch only the second layer to the new visualization type if layerId is specified (chart switch case)', () => { + const state = exampleState(); + state.layers[1] = { ...state.layers[0] }; + state.layers[1].layerId = 'second'; + state.layers[2] = { ...state.layers[0] }; + state.layers[2].layerId = 'third'; + const newType = 'bar'; + const newState = xyVisualization.switchVisualizationType!(newType, state, 'first'); + expect((newState.layers[0] as XYDataLayerConfig).seriesType).toEqual(newType); + expect((newState.layers[1] as XYDataLayerConfig).seriesType).toEqual('area'); + expect((newState.layers[2] as XYDataLayerConfig).seriesType).toEqual('area'); + }); + }); }); diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 64a2ad4fc2754..6f17a2253a35e 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -263,14 +263,15 @@ export const getXyVisualization = ({ getDescription, switchVisualizationType(seriesType: string, state: State, layerId?: string) { - const dataLayer = state.layers.find((l) => l.layerId === layerId); + const dataLayer = layerId + ? state.layers.find((l) => l.layerId === layerId) + : state.layers.at(0); if (dataLayer && !isDataLayer(dataLayer)) { throw new Error('Cannot switch series type for non-data layer'); } if (!dataLayer) { return state; } - // todo: test how they switch between percentage etc const currentStackingType = stackingTypes.find(({ subtypes }) => subtypes.includes(dataLayer.seriesType) ); From cb309882166172fa59aac0d0e839edcd1ae43c61 Mon Sep 17 00:00:00 2001 From: Kylie Meli <kylie.geller@elastic.co> Date: Tue, 15 Oct 2024 18:03:42 -0400 Subject: [PATCH 17/31] [Automatic Import] Fixing only show cel generation flow when user selects cel input (#196356) ## Summary Fixing the Automatic Import flow so that cel generation only occurs when user selects the CEL input in the dropdown. --- .../create_integration_assistant.test.tsx | 22 +++++++- .../create_integration_assistant.tsx | 8 ++- .../footer/footer.test.tsx | 56 ++++++++++++------- .../footer/footer.tsx | 11 ++-- .../mocks/state.ts | 2 + .../create_integration_assistant/state.ts | 6 ++ .../data_stream_step/data_stream_step.tsx | 9 ++- 7 files changed, 83 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx index b6fe577865822..ca4d50958005d 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx @@ -17,6 +17,7 @@ export const defaultInitialState: State = { connector: undefined, integrationSettings: undefined, isGenerating: false, + hasCelInput: false, result: undefined, }; const mockInitialState = jest.fn((): State => defaultInitialState); @@ -168,9 +169,9 @@ describe('CreateIntegration with generateCel enabled', () => { } as never); }); - describe('when step is 5', () => { + describe('when step is 5 and has celInput', () => { beforeEach(() => { - mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5 }); + mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5, hasCelInput: true }); }); it('should render cel input', () => { @@ -184,9 +185,24 @@ describe('CreateIntegration with generateCel enabled', () => { }); }); + describe('when step is 5 and does not have celInput', () => { + beforeEach(() => { + mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5 }); + }); + + it('should render deploy', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); + }); + }); + describe('when step is 6', () => { beforeEach(() => { - mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 6 }); + mockInitialState.mockReturnValueOnce({ + ...defaultInitialState, + step: 6, + celInputResult: { program: 'program', stateSettings: {}, redactVars: [] }, + }); }); it('should render review', () => { diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx index 1297e7c975e3b..72e085e19920a 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx @@ -44,6 +44,9 @@ export const CreateIntegrationAssistant = React.memo(() => { setIsGenerating: (payload) => { dispatch({ type: 'SET_IS_GENERATING', payload }); }, + setHasCelInput: (payload) => { + dispatch({ type: 'SET_HAS_CEL_INPUT', payload }); + }, setResult: (payload) => { dispatch({ type: 'SET_GENERATED_RESULT', payload }); }, @@ -93,7 +96,7 @@ export const CreateIntegrationAssistant = React.memo(() => { /> )} {state.step === 5 && - (isGenerateCelEnabled ? ( + (isGenerateCelEnabled && state.hasCelInput ? ( <CelInputStep integrationSettings={state.integrationSettings} connector={state.connector} @@ -107,7 +110,7 @@ export const CreateIntegrationAssistant = React.memo(() => { /> ))} - {isGenerateCelEnabled && state.step === 6 && ( + {isGenerateCelEnabled && state.celInputResult && state.step === 6 && ( <ReviewCelStep isGenerating={state.isGenerating} celInputResult={state.celInputResult} @@ -125,6 +128,7 @@ export const CreateIntegrationAssistant = React.memo(() => { <Footer currentStep={state.step} isGenerating={state.isGenerating} + hasCelInput={state.hasCelInput} isNextStepEnabled={isNextStepEnabled} /> </KibanaPageTemplate> diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx index 900a72ab272a0..1ca79210bb19f 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx @@ -42,9 +42,12 @@ describe('Footer', () => { describe('when rendered', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={1} isGenerating={false} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={1} isGenerating={false} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); it('should render footer buttons component', () => { expect(result.queryByTestId('buttonsFooter')).toBeInTheDocument(); @@ -66,9 +69,12 @@ describe('Footer', () => { describe('when step is 1', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={1} isGenerating={false} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={1} isGenerating={false} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); describe('when next button is clicked', () => { @@ -112,9 +118,12 @@ describe('Footer', () => { describe('when step is 2', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={2} isGenerating={false} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={2} isGenerating={false} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); describe('when next button is clicked', () => { @@ -159,9 +168,12 @@ describe('Footer', () => { describe('when it is not generating', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={3} isGenerating={false} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={3} isGenerating={false} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); describe('when next button is clicked', () => { @@ -205,9 +217,12 @@ describe('Footer', () => { describe('when it is generating', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={3} isGenerating={true} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={3} isGenerating={true} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); it('should render the loader', () => { @@ -219,9 +234,12 @@ describe('Footer', () => { describe('when step is 4', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={4} isGenerating={false} isNextStepEnabled />, { - wrapper, - }); + result = render( + <Footer currentStep={4} isGenerating={false} hasCelInput={false} isNextStepEnabled />, + { + wrapper, + } + ); }); describe('when next button is clicked', () => { @@ -265,7 +283,7 @@ describe('Footer', () => { describe('when next step is disabled', () => { let result: RenderResult; beforeEach(() => { - result = render(<Footer currentStep={1} isGenerating={false} />, { + result = render(<Footer currentStep={1} isGenerating={false} hasCelInput={false} />, { wrapper, }); }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx index 9a2f862264e27..839d751e6f380 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx @@ -45,11 +45,12 @@ AnalyzeCelButtonText.displayName = 'AnalyzeCelButtonText'; interface FooterProps { currentStep: State['step']; isGenerating: State['isGenerating']; + hasCelInput: State['hasCelInput']; isNextStepEnabled?: boolean; } export const Footer = React.memo<FooterProps>( - ({ currentStep, isGenerating, isNextStepEnabled = false }) => { + ({ currentStep, isGenerating, hasCelInput, isNextStepEnabled = false }) => { const telemetry = useTelemetry(); const { setStep, setIsGenerating } = useActions(); const navigate = useNavigate(); @@ -77,18 +78,18 @@ export const Footer = React.memo<FooterProps>( if (currentStep === 3) { return <AnalyzeButtonText isGenerating={isGenerating} />; } - if (currentStep === 4 && !isGenerateCelEnabled) { + if (currentStep === 4 && (!isGenerateCelEnabled || !hasCelInput)) { return i18n.ADD_TO_ELASTIC; } - if (currentStep === 5 && isGenerateCelEnabled) { + if (currentStep === 5 && isGenerateCelEnabled && hasCelInput) { return <AnalyzeCelButtonText isGenerating={isGenerating} />; } if (currentStep === 6 && isGenerateCelEnabled) { return i18n.ADD_TO_ELASTIC; } - }, [currentStep, isGenerating, isGenerateCelEnabled]); + }, [currentStep, isGenerating, hasCelInput, isGenerateCelEnabled]); - if (currentStep === 7 || (currentStep === 5 && !isGenerateCelEnabled)) { + if (currentStep === 7 || (currentStep === 5 && (!isGenerateCelEnabled || !hasCelInput))) { return <ButtonsFooter cancelButtonText={i18n.CLOSE} />; } return ( diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts index 452d5e65a972c..c25a78a35416e 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts @@ -423,6 +423,7 @@ export const mockState: State = { logSamples: rawSamples, }, isGenerating: false, + hasCelInput: false, result, }; @@ -431,6 +432,7 @@ export const mockActions: Actions = { setConnector: jest.fn(), setIntegrationSettings: jest.fn(), setIsGenerating: jest.fn(), + setHasCelInput: jest.fn(), setResult: jest.fn(), setCelInputResult: jest.fn(), }; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts index 0492012ab8686..1e7b22128843b 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts @@ -13,6 +13,7 @@ export interface State { connector?: AIConnector; integrationSettings?: IntegrationSettings; isGenerating: boolean; + hasCelInput: boolean; result?: { pipeline: Pipeline; docs: Docs; @@ -26,6 +27,7 @@ export const initialState: State = { connector: undefined, integrationSettings: undefined, isGenerating: false, + hasCelInput: false, result: undefined, }; @@ -34,6 +36,7 @@ type Action = | { type: 'SET_CONNECTOR'; payload: State['connector'] } | { type: 'SET_INTEGRATION_SETTINGS'; payload: State['integrationSettings'] } | { type: 'SET_IS_GENERATING'; payload: State['isGenerating'] } + | { type: 'SET_HAS_CEL_INPUT'; payload: State['hasCelInput'] } | { type: 'SET_GENERATED_RESULT'; payload: State['result'] } | { type: 'SET_CEL_INPUT_RESULT'; payload: State['celInputResult'] }; @@ -52,6 +55,8 @@ export const reducer = (state: State, action: Action): State => { return { ...state, integrationSettings: action.payload }; case 'SET_IS_GENERATING': return { ...state, isGenerating: action.payload }; + case 'SET_HAS_CEL_INPUT': + return { ...state, hasCelInput: action.payload }; case 'SET_GENERATED_RESULT': return { ...state, @@ -70,6 +75,7 @@ export interface Actions { setConnector: (payload: State['connector']) => void; setIntegrationSettings: (payload: State['integrationSettings']) => void; setIsGenerating: (payload: State['isGenerating']) => void; + setHasCelInput: (payload: State['hasCelInput']) => void; setResult: (payload: State['result']) => void; setCelInputResult: (payload: State['celInputResult']) => void; } diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/data_stream_step.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/data_stream_step.tsx index 4e93d17adedd5..4b505fb7062d6 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/data_stream_step.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/data_stream_step/data_stream_step.tsx @@ -53,7 +53,8 @@ interface DataStreamStepProps { } export const DataStreamStep = React.memo<DataStreamStepProps>( ({ integrationSettings, connector, isGenerating }) => { - const { setIntegrationSettings, setIsGenerating, setStep, setResult } = useActions(); + const { setIntegrationSettings, setIsGenerating, setHasCelInput, setStep, setResult } = + useActions(); const { isLoading: isLoadingPackageNames, packageNames } = useLoadPackageNames(); // this is used to avoid duplicate names const [name, setName] = useState<string>(integrationSettings?.name ?? ''); @@ -99,9 +100,13 @@ export const DataStreamStep = React.memo<DataStreamStepProps>( setIntegrationValues({ dataStreamDescription: e.target.value }), inputTypes: (options: EuiComboBoxOptionOption[]) => { setIntegrationValues({ inputTypes: options.map((option) => option.value as InputType) }); + setHasCelInput( + // the cel value here comes from the input type options defined above + options.map((option) => option.value as InputType).includes('cel' as InputType) + ); }, }; - }, [setIntegrationValues, setInvalidFields, packageNames]); + }, [setIntegrationValues, setInvalidFields, setHasCelInput, packageNames]); useEffect(() => { // Pre-populates the name from the title set in the previous step. From d89f32a6aca0b522c606e5aec668cee5a3267d4a Mon Sep 17 00:00:00 2001 From: "Quynh Nguyen (Quinn)" <43350163+qn895@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:04:04 -0500 Subject: [PATCH 18/31] [ML] Add control to show or hide empty fields in dropdown in Transform (#195485) ## Summary Follow up of https://github.com/elastic/kibana/pull/186670. This PR adds a new control show or hide empty fields in dropdowns in Transform. #### Transform Pivot transform creation https://github.com/user-attachments/assets/35366671-c7a0-4ba1-ae24-ae3d965a2d69 Latest transform creation <img width="1473" alt="image" src="https://github.com/user-attachments/assets/db53e7ed-17d5-44d7-93ab-1d0c5ca22f20"> ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../option_list_popover.tsx | 4 +- .../option_list_with_stats.tsx | 18 ++++-- .../options_list_with_stats/types.ts | 3 +- .../aggregation_dropdown/dropdown.tsx | 5 +- .../aggregation_list/sub_aggs_section.tsx | 41 ++++++++++++- .../pivot_configuration.tsx | 59 +++++++++++-------- .../step_define/latest_function_form.tsx | 19 +++--- .../functional/services/transform/wizard.ts | 15 ++++- 8 files changed, 119 insertions(+), 45 deletions(-) diff --git a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_popover.tsx b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_popover.tsx index 77b5f8a0d8b15..40b47acad3338 100644 --- a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_popover.tsx +++ b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_popover.tsx @@ -107,7 +107,9 @@ export const OptionsListPopover = ({ }: OptionsListPopoverProps) => { const { populatedFields } = useFieldStatsFlyoutContext(); - const [showEmptyFields, setShowEmptyFields] = useState(false); + const [showEmptyFields, setShowEmptyFields] = useState( + populatedFields ? !(populatedFields.size > 0) : true + ); const id = useMemo(() => htmlIdGenerator()(), []); const filteredOptions = useMemo(() => { diff --git a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_with_stats.tsx b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_with_stats.tsx index 244b2d6a511a9..4038047450d5a 100644 --- a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_with_stats.tsx +++ b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/option_list_with_stats.tsx @@ -7,7 +7,11 @@ import type { FC } from 'react'; import React, { useMemo, useState } from 'react'; -import type { EuiComboBoxOptionOption, EuiComboBoxSingleSelectionShape } from '@elastic/eui'; +import type { + EuiComboBoxOptionOption, + EuiComboBoxSingleSelectionShape, + EuiFormControlLayoutProps, +} from '@elastic/eui'; import { EuiInputPopover, htmlIdGenerator, EuiFormControlLayout, EuiFieldText } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; @@ -18,8 +22,6 @@ import type { DropDownLabel } from './types'; const MIN_POPOVER_WIDTH = 400; export const optionCss = css` - display: flex; - align-items: center; .euiComboBoxOption__enterBadge { display: none; } @@ -31,7 +33,8 @@ export const optionCss = css` } `; -interface OptionListWithFieldStatsProps { +interface OptionListWithFieldStatsProps + extends Pick<EuiFormControlLayoutProps, 'prepend' | 'compressed'> { options: DropDownLabel[]; placeholder?: string; 'aria-label'?: string; @@ -58,6 +61,8 @@ export const OptionListWithFieldStats: FC<OptionListWithFieldStatsProps> = ({ isDisabled, isLoading, isClearable = true, + prepend, + compressed, 'aria-label': ariaLabel, 'data-test-subj': dataTestSubj, }) => { @@ -68,13 +73,12 @@ export const OptionListWithFieldStats: FC<OptionListWithFieldStatsProps> = ({ const comboBoxOptions: DropDownLabel[] = useMemo( () => Array.isArray(options) - ? options.map(({ isEmpty, hideTrigger: hideInspectButton, ...o }) => ({ + ? options.map(({ isEmpty, ...o }) => ({ ...o, css: optionCss, // Change data-is-empty- because EUI is passing all props to dom element // so isEmpty is invalid, but we need this info to render option correctly 'data-is-empty': isEmpty, - 'data-hide-inspect': hideInspectButton, })) : [], [options] @@ -89,6 +93,8 @@ export const OptionListWithFieldStats: FC<OptionListWithFieldStatsProps> = ({ id={popoverId} input={ <EuiFormControlLayout + prepend={prepend} + compressed={compressed} fullWidth={fullWidth} // Adding classname to make functional tests similar to EuiComboBox className={singleSelection ? 'euiComboBox__inputWrap--plainText' : ''} diff --git a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/types.ts b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/types.ts index ef95daa38ea03..419808b804e21 100644 --- a/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/types.ts +++ b/x-pack/packages/ml/field_stats_flyout/options_list_with_stats/types.ts @@ -9,8 +9,9 @@ import type { EuiComboBoxOptionOption, EuiSelectableOption } from '@elastic/eui' import type { Aggregation, Field } from '@kbn/ml-anomaly-utils'; interface BaseOption<T> { - key?: string; label: string | React.ReactNode; + key?: string; + value?: string | number | string[]; isEmpty?: boolean; hideTrigger?: boolean; 'data-is-empty'?: boolean; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index eaa9faee8de53..1782fa2df3bb8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { EuiComboBoxOptionsListProps, EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiComboBox } from '@elastic/eui'; +import { OptionListWithFieldStats } from '@kbn/ml-field-stats-flyout/options_list_with_stats/option_list_with_stats'; interface Props { options: EuiComboBoxOptionOption[]; @@ -30,7 +30,7 @@ export const DropDown: React.FC<Props> = ({ isDisabled, }) => { return ( - <EuiComboBox + <OptionListWithFieldStats fullWidth placeholder={placeholder} singleSelection={{ asPlainText: true }} @@ -40,7 +40,6 @@ export const DropDown: React.FC<Props> = ({ isClearable={false} data-test-subj={testSubj} isDisabled={isDisabled} - renderOption={renderOption} /> ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/sub_aggs_section.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/sub_aggs_section.tsx index d1fa84056a0cd..498563bc23a5f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/sub_aggs_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/sub_aggs_section.tsx @@ -11,11 +11,14 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiSpacer, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { FieldStatsInfoButton, useFieldStatsTrigger } from '@kbn/ml-field-stats-flyout'; import { AggListForm } from './list_form'; import { DropDown } from '../aggregation_dropdown'; import type { PivotAggsConfig } from '../../../../common'; import { PivotConfigurationContext } from '../pivot_configuration/pivot_configuration'; import { MAX_NESTING_SUB_AGGS } from '../../../../common/pivot_aggs'; +import type { DropDownOptionWithField } from '../step_define/common/get_pivot_dropdown_options'; +import type { DropDownOption } from '../../../../common/dropdown'; /** * Component for managing sub-aggregation of the provided @@ -54,11 +57,47 @@ export const SubAggsSection: FC<{ item: PivotAggsConfig }> = ({ item }) => { } return nestingLevel <= MAX_NESTING_SUB_AGGS; }, [item]); + const { handleFieldStatsButtonClick, populatedFields } = useFieldStatsTrigger(); + const options = useMemo(() => { + const opts: EuiComboBoxOptionOption[] = []; + state.aggOptions.forEach(({ label, field, options: aggOptions }: DropDownOptionWithField) => { + const isEmpty = populatedFields && field.id ? !populatedFields.has(field.id) : false; + + const aggOption: DropDownOption = { + isGroupLabel: true, + key: field.id, + searchableLabel: label, + // @ts-ignore Purposefully passing label as element instead of string + // for more robust rendering + label: ( + <FieldStatsInfoButton + isEmpty={populatedFields && !populatedFields.has(field.id)} + field={field} + label={label} + onButtonClick={handleFieldStatsButtonClick} + /> + ), + }; + + if (aggOptions.length) { + opts.push(aggOption); + opts.push( + ...aggOptions.map((o) => ({ + ...o, + isEmpty, + isGroupLabel: false, + searchableLabel: o.label, + })) + ); + } + }); + return opts; + }, [handleFieldStatsButtonClick, populatedFields, state.aggOptions]); const dropdown = ( <DropDown changeHandler={addSubAggHandler} - options={state.aggOptions} + options={options} placeholder={i18n.translate('xpack.transform.stepDefineForm.addSubAggregationPlaceholder', { defaultMessage: 'Add a sub-aggregation ...', })} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx index 798837a1a693f..129f4766d9f28 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx @@ -12,7 +12,6 @@ import { EuiFormRow, type EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useFieldStatsTrigger, FieldStatsInfoButton } from '@kbn/ml-field-stats-flyout'; - import { type DropDownOptionWithField } from '../step_define/common/get_pivot_dropdown_options'; import type { DropDownOption } from '../../../../common'; import { AggListForm } from '../aggregation_list'; @@ -41,28 +40,42 @@ export const PivotConfiguration: FC<StepDefineFormHook['pivotConfig']> = memo( const { aggList, aggOptions, aggOptionsData, groupByList, groupByOptions, groupByOptionsData } = state; - const aggOptionsWithFieldStats: EuiComboBoxOptionOption[] = useMemo( - () => - aggOptions.map(({ label, field, options }: DropDownOptionWithField) => { - const aggOption: DropDownOption = { - isGroupLabelOption: true, - key: field.id, - // @ts-ignore Purposefully passing label as element instead of string - // for more robust rendering - label: ( - <FieldStatsInfoButton - isEmpty={populatedFields && !populatedFields.has(field.id)} - field={field} - label={label} - onButtonClick={handleFieldStatsButtonClick} - /> - ), - options: options ?? [], - }; - return aggOption; - }), - [aggOptions, handleFieldStatsButtonClick, populatedFields] - ); + const aggOptionsWithFieldStats: EuiComboBoxOptionOption[] = useMemo(() => { + const opts: EuiComboBoxOptionOption[] = []; + aggOptions.forEach(({ label, field, options }: DropDownOptionWithField) => { + const isEmpty = populatedFields && field.id ? !populatedFields.has(field.id) : false; + + const aggOption: DropDownOption = { + isGroupLabel: true, + key: field.id, + searchableLabel: label, + // @ts-ignore Purposefully passing label as element instead of string + // for more robust rendering + label: ( + <FieldStatsInfoButton + isEmpty={populatedFields && !populatedFields.has(field.id)} + field={field} + label={label} + onButtonClick={handleFieldStatsButtonClick} + /> + ), + }; + + if (options.length) { + opts.push(aggOption); + opts.push( + ...options.map((o) => ({ + ...o, + isEmpty, + isGroupLabel: false, + searchableLabel: o.label, + })) + ); + } + }); + return opts; + }, [aggOptions, handleFieldStatsButtonClick, populatedFields]); + return ( <PivotConfigurationContext.Provider value={{ actions, state }}> <EuiFormRow diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx index 9ded43a82c71a..7c0439e3761df 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx @@ -10,7 +10,8 @@ import React, { type FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonIcon, EuiCallOut, EuiComboBox, EuiCopy, EuiFormRow } from '@elastic/eui'; -import { useFieldStatsTrigger } from '@kbn/ml-field-stats-flyout'; +import type { DropDownLabel } from '@kbn/ml-field-stats-flyout'; +import { OptionListWithFieldStats, useFieldStatsTrigger } from '@kbn/ml-field-stats-flyout'; import type { LatestFunctionService } from './hooks/use_latest_function_config'; interface LatestFunctionFormProps { @@ -73,7 +74,7 @@ export const LatestFunctionForm: FC<LatestFunctionFormProps> = ({ > <> {latestFunctionService.sortFieldOptions.length > 0 && ( - <EuiComboBox + <OptionListWithFieldStats fullWidth placeholder={i18n.translate('xpack.transform.stepDefineForm.sortPlaceholder', { defaultMessage: 'Add a date field ...', @@ -83,15 +84,19 @@ export const LatestFunctionForm: FC<LatestFunctionFormProps> = ({ selectedOptions={ latestFunctionService.config.sort ? [latestFunctionService.config.sort] : [] } - onChange={(selected) => { - latestFunctionService.updateLatestFunctionConfig({ - sort: { value: selected[0].value, label: selected[0].label as string }, - }); + onChange={(selected: DropDownLabel[]) => { + if (typeof selected[0].value === 'string') { + latestFunctionService.updateLatestFunctionConfig({ + sort: { + value: selected[0].value, + label: selected[0].label?.toString(), + }, + }); + } closeFlyout(); }} isClearable={false} data-test-subj="transformWizardSortFieldSelector" - renderOption={renderOption} /> )} {latestFunctionService.sortFieldOptions.length === 0 && ( diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 19cb6c15a9f56..7d113c30ffeb1 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -444,7 +444,10 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi }, async setSortFieldValue(identificator: string, label: string) { - await comboBox.set('transformWizardSortFieldSelector > comboBoxInput', identificator); + await ml.commonUI.setOptionsListWithFieldStatsValue( + 'transformWizardSortFieldSelector > comboBoxInput', + identificator + ); await this.assertSortFieldInputValue(identificator); }, @@ -507,7 +510,10 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi expectedLabel: string, expectedIntervalLabel?: string ) { - await comboBox.set('transformGroupBySelection > comboBoxInput', identifier); + await ml.commonUI.setOptionsListWithFieldStatsValue( + 'transformGroupBySelection > comboBoxInput', + identifier + ); await this.assertGroupByInputValue([]); await this.assertGroupByEntryExists(index, expectedLabel, expectedIntervalLabel); }, @@ -582,7 +588,10 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi formData?: Record<string, any>, parentSelector = '' ) { - await comboBox.set(this.getAggComboBoxInputSelector(parentSelector), identifier); + await ml.commonUI.setOptionsListWithFieldStatsValue( + this.getAggComboBoxInputSelector(parentSelector), + identifier + ); await this.assertAggregationInputValue([], parentSelector); await this.assertAggregationEntryExists(index, expectedLabel, parentSelector); From 5fbec1febc0050b2faaba7a25cf4aba9bf0cea1f Mon Sep 17 00:00:00 2001 From: mohamedhamed-ahmed <mohamed.ahmed@elastic.co> Date: Tue, 15 Oct 2024 23:07:38 +0100 Subject: [PATCH 19/31] [Dataset Quality] Hide unreachable links (#196302) closes https://github.com/elastic/kibana/issues/196256 https://github.com/user-attachments/assets/5cec1296-a17a-43bb-8530-99e7261a189a --- .../logs_overview_degraded_fields.tsx | 5 ++-- .../components/dataset_quality_link.tsx | 28 +++++++++++-------- .../components/logs_explorer_top_nav_menu.tsx | 14 +++++++--- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_degraded_fields.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_degraded_fields.tsx index 593ea978db153..3a244dcd5eb3c 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_degraded_fields.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_degraded_fields.tsx @@ -270,13 +270,12 @@ const DatasetQualityLink = React.memo( urlService: BrowserUrlService; dataStream: string | undefined; }) => { - if (!dataStream) { - return null; - } const locator = urlService.locators.get<DataQualityDetailsLocatorParams>( DATA_QUALITY_DETAILS_LOCATOR_ID ); + if (!locator || !dataStream) return null; + const datasetQualityUrl = locator?.getRedirectUrl({ dataStream }); const navigateToDatasetQuality = () => { diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/dataset_quality_link.tsx b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/dataset_quality_link.tsx index 6610db470014b..05c74a9a1c82a 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/dataset_quality_link.tsx +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/dataset_quality_link.tsx @@ -8,7 +8,7 @@ import { EuiHeaderLink } from '@elastic/eui'; import { LogsExplorerPublicState } from '@kbn/logs-explorer-plugin/public'; import { getRouterLinkProps } from '@kbn/router-utils'; -import { BrowserUrlService } from '@kbn/share-plugin/public'; +import { LocatorPublic } from '@kbn/share-plugin/public'; import { MatchedStateFromActor } from '@kbn/xstate-utils'; import { useActor } from '@xstate/react'; import React from 'react'; @@ -20,20 +20,28 @@ import { } from '../state_machines/observability_logs_explorer/src'; import { useKibanaContextForPlugin } from '../utils/use_kibana'; -export const ConnectedDatasetQualityLink = React.memo(() => { +export const ConnectedDatasetQualityLink = () => { const { services: { share: { url }, }, } = useKibanaContextForPlugin(); const [pageState] = useActor(useObservabilityLogsExplorerPageStateContext()); + const locator = url.locators.get<DataQualityLocatorParams>(DATA_QUALITY_LOCATOR_ID); - if (pageState.matches({ initialized: 'validLogsExplorerState' })) { - return <DatasetQualityLink urlService={url} pageState={pageState} />; - } else { - return <DatasetQualityLink urlService={url} />; + if (!locator) { + return null; } -}); + + return ( + <DatasetQualityLink + locator={locator} + pageState={ + pageState.matches({ initialized: 'validLogsExplorerState' }) ? pageState : undefined + } + /> + ); +}; type InitializedPageState = MatchedStateFromActor< ObservabilityLogsExplorerService, @@ -62,14 +70,12 @@ const constructLocatorParams = ( export const DatasetQualityLink = React.memo( ({ - urlService, + locator, pageState, }: { - urlService: BrowserUrlService; + locator: LocatorPublic<DataQualityLocatorParams>; pageState?: InitializedPageState; }) => { - const locator = urlService.locators.get<DataQualityLocatorParams>(DATA_QUALITY_LOCATOR_ID); - const locatorParams: DataQualityLocatorParams = pageState ? constructLocatorParams(pageState.context.logsExplorerState) : {}; diff --git a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/logs_explorer_top_nav_menu.tsx b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/logs_explorer_top_nav_menu.tsx index fb96bdbfc65f1..c3f91b3bf8660 100644 --- a/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/logs_explorer_top_nav_menu.tsx +++ b/x-pack/plugins/observability_solution/observability_logs_explorer/public/components/logs_explorer_top_nav_menu.tsx @@ -70,8 +70,7 @@ const ProjectTopNav = () => { <EuiHeaderSectionItem> <EuiHeaderLinks gutterSize="xs"> <ConnectedDiscoverLink /> - <VerticalRule /> - <ConnectedDatasetQualityLink /> + <ConditionalVerticalRule Component={ConnectedDatasetQualityLink()} /> <VerticalRule /> <FeedbackLink /> <VerticalRule /> @@ -147,8 +146,7 @@ const ClassicTopNav = () => { <EuiHeaderSectionItem> <EuiHeaderLinks gutterSize="xs"> <ConnectedDiscoverLink /> - <VerticalRule /> - <ConnectedDatasetQualityLink /> + <ConditionalVerticalRule Component={ConnectedDatasetQualityLink()} /> <VerticalRule /> <AlertsPopover /> <VerticalRule /> @@ -165,3 +163,11 @@ const VerticalRule = styled.span` height: 20px; background-color: ${euiThemeVars.euiColorLightShade}; `; + +const ConditionalVerticalRule = ({ Component }: { Component: JSX.Element | null }) => + Component && ( + <> + <VerticalRule /> + {Component} + </> + ); From d9cd17bdbd47d66968bd5fda6fb32a08134fbc2d Mon Sep 17 00:00:00 2001 From: Ying Mao <ying.mao@elastic.co> Date: Tue, 15 Oct 2024 18:22:46 -0400 Subject: [PATCH 20/31] Adding model versions for all remaining so types without model versions (#195500) Resolves https://github.com/elastic/kibana/issues/184618 ## Summary Adds v1 schemas for all remaining Response Ops owned saved object types: * `connector_token` * `api_key_pending_invalidation` * `maintenance-window` * `rules-settings` ## To Verify 1. Run ES and Kibana on `main` and create saved objects for each of the above types: a. Create an OAuth ServiceNow ITOM connector to create a `connector_token` saved object b. Create a rule, let it run, and then delete the rule. This will create an `api_key_pending_invalidation` SO and 2 `rules-settings` SOs c. Create some maintenance windows, both with and without filters 2. Keep ES running and switch to this branch and restart Kibana. Then verify you can read and modify the existing SOs with no errors a. Test the ServiceNow ITOM connector, which should read the `connector_token` SO b. Modify the rules settings and then run a rule to ensure they're loaded with no errors c. Load the maintenance window UI and edit a MW Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../check_registered_types.test.ts | 8 +- .../actions/server/saved_objects/index.ts | 8 +- .../connector_token_model_versions.ts | 19 ++++ .../saved_objects/model_versions/index.ts | 1 + .../schemas/raw_connector_token/index.ts | 8 ++ .../schemas/raw_connector_token/v1.ts | 17 ++++ .../alerting/server/saved_objects/index.ts | 11 ++- ...key_pending_invalidation_model_versions.ts | 22 +++++ .../saved_objects/model_versions/index.ts | 3 + .../maintenance_window_model_versions.ts | 19 ++++ .../rules_settings_model_versions.ts | 19 ++++ .../raw_api_key_pending_invalidation/index.ts | 8 ++ .../raw_api_key_pending_invalidation/v1.ts | 13 +++ .../schemas/raw_maintenance_window/index.ts | 8 ++ .../schemas/raw_maintenance_window/v1.ts | 87 +++++++++++++++++++ .../schemas/raw_rules_settings/index.ts | 8 ++ .../schemas/raw_rules_settings/v1.ts | 31 +++++++ 17 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/actions/server/saved_objects/model_versions/connector_token_model_versions.ts create mode 100644 x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/index.ts create mode 100644 x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/v1.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/model_versions/rules_settings_model_versions.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v1.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/index.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/v1.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/index.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/v1.ts diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 53198f9746cfa..e8c7d41c2a4fd 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -60,7 +60,7 @@ describe('checking migration metadata changes on all registered SO types', () => "action_task_params": "b50cb5c8a493881474918e8d4985e61374ca4c30", "ad_hoc_run_params": "d4e3c5c794151d0a4f5c71e886b2aa638da73ad2", "alert": "05b07040b12ff45ab642f47464e8a6c903cf7b86", - "api_key_pending_invalidation": "1399e87ca37b3d3a65d269c924eda70726cfe886", + "api_key_pending_invalidation": "8f5554d1984854011b8392d9a6f7ef985bcac03c", "apm-custom-dashboards": "b67128f78160c288bd7efe25b2da6e2afd5e82fc", "apm-indices": "8a2d68d415a4b542b26b0d292034a28ffac6fed4", "apm-server-schema": "58a8c6468edae3d1dc520f0134f59cf3f4fd7eff", @@ -83,7 +83,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cloud-security-posture-settings": "e0f61c68bbb5e4cfa46ce8994fa001e417df51ca", "config": "179b3e2bc672626aafce3cf92093a113f456af38", "config-global": "8e8a134a2952df700d7d4ec51abb794bbd4cf6da", - "connector_token": "5a9ac29fe9c740eb114e9c40517245c71706b005", + "connector_token": "79977ea2cb1530ba7e315b95c1b5a524b622a6b3", "core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff", "csp-rule-template": "c151324d5f85178169395eecb12bac6b96064654", "dashboard": "211e9ca30f5a95d5f3c27b1bf2b58e6cfa0c9ae9", @@ -131,7 +131,7 @@ describe('checking migration metadata changes on all registered SO types', () => "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", "links": "1dd432cc94619a513b75cec43660a50be7aadc90", - "maintenance-window": "d893544460abad56ff7a0e25b78f78776dfe10d1", + "maintenance-window": "bf36863f5577c2d22625258bdad906eeb4cccccc", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", "metrics-explorer-view": "98cf395d0e87b89ab63f173eae16735584a8ff42", @@ -147,7 +147,7 @@ describe('checking migration metadata changes on all registered SO types', () => "policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352", "query": "501bece68f26fe561286a488eabb1a8ab12f1137", "risk-engine-configuration": "bab237d09c2e7189dddddcb1b28f19af69755efb", - "rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f", + "rules-settings": "ba57ef1881b3dcbf48fbfb28902d8f74442190b2", "sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5", "search": "0aa6eefb37edd3145be340a8b67779c2ca578b22", "search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1", diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts index a4d7886091fe5..102d2dda76225 100644 --- a/x-pack/plugins/actions/server/saved_objects/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/index.ts @@ -13,7 +13,6 @@ import type { import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { getOldestIdleActionTask } from '@kbn/task-manager-plugin/server'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; -import { actionTaskParamsModelVersions } from './model_versions'; import { actionMappings, actionTaskParamsMappings, connectorTokenMappings } from './mappings'; import { getActionsMigrations } from './actions_migrations'; import { getActionTaskParamsMigrations } from './action_task_params_migrations'; @@ -26,7 +25,11 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from '../constants/saved_objects'; -import { connectorModelVersions } from './model_versions'; +import { + actionTaskParamsModelVersions, + connectorModelVersions, + connectorTokenModelVersions, +} from './model_versions'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, @@ -121,6 +124,7 @@ export function setupSavedObjects( management: { importableAndExportable: false, }, + modelVersions: connectorTokenModelVersions, }); encryptedSavedObjects.registerType({ diff --git a/x-pack/plugins/actions/server/saved_objects/model_versions/connector_token_model_versions.ts b/x-pack/plugins/actions/server/saved_objects/model_versions/connector_token_model_versions.ts new file mode 100644 index 0000000000000..604e9866ca2de --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/model_versions/connector_token_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawConnectorTokenSchemaV1 } from '../schemas/raw_connector_token'; + +export const connectorTokenModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawConnectorTokenSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawConnectorTokenSchemaV1, + }, + }, +}; diff --git a/x-pack/plugins/actions/server/saved_objects/model_versions/index.ts b/x-pack/plugins/actions/server/saved_objects/model_versions/index.ts index fdfc6adecd8e0..f573864ffbec4 100644 --- a/x-pack/plugins/actions/server/saved_objects/model_versions/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/model_versions/index.ts @@ -6,4 +6,5 @@ */ export { connectorModelVersions } from './connector_model_versions'; +export { connectorTokenModelVersions } from './connector_token_model_versions'; export { actionTaskParamsModelVersions } from './action_task_params_model_versions'; diff --git a/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/index.ts b/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/index.ts new file mode 100644 index 0000000000000..66d20c740f8d2 --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawConnectorTokenSchema as rawConnectorTokenSchemaV1 } from './v1'; diff --git a/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/v1.ts b/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/v1.ts new file mode 100644 index 0000000000000..be91cf266b5bc --- /dev/null +++ b/x-pack/plugins/actions/server/saved_objects/schemas/raw_connector_token/v1.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawConnectorTokenSchema = schema.object({ + createdAt: schema.string(), + connectorId: schema.string(), + expiresAt: schema.string(), + token: schema.string(), + tokenType: schema.string(), + updatedAt: schema.string(), +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index a3bb0b4f0afe8..8e76f28ff7fb8 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -28,7 +28,13 @@ import { RULES_SETTINGS_SAVED_OBJECT_TYPE, MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, } from '../../common'; -import { ruleModelVersions, adHocRunParamsModelVersions } from './model_versions'; +import { + adHocRunParamsModelVersions, + apiKeyPendingInvalidationModelVersions, + maintenanceWindowModelVersions, + ruleModelVersions, + rulesSettingsModelVersions, +} from './model_versions'; export const RULE_SAVED_OBJECT_TYPE = 'alert'; export const AD_HOC_RUN_SAVED_OBJECT_TYPE = 'ad_hoc_run_params'; @@ -145,6 +151,7 @@ export function setupSavedObjects( }, }, }, + modelVersions: apiKeyPendingInvalidationModelVersions, }); savedObjects.registerType({ @@ -153,6 +160,7 @@ export function setupSavedObjects( hidden: true, namespaceType: 'single', mappings: rulesSettingsMappings, + modelVersions: rulesSettingsModelVersions, }); savedObjects.registerType({ @@ -161,6 +169,7 @@ export function setupSavedObjects( hidden: true, namespaceType: 'multiple-isolated', mappings: maintenanceWindowMappings, + modelVersions: maintenanceWindowModelVersions, }); savedObjects.registerType({ diff --git a/x-pack/plugins/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts b/x-pack/plugins/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts new file mode 100644 index 0000000000000..0d6456a9b155a --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/model_versions/api_key_pending_invalidation_model_versions.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawApiKeyPendingInvalidationSchemaV1 } from '../schemas/raw_api_key_pending_invalidation'; + +export const apiKeyPendingInvalidationModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawApiKeyPendingInvalidationSchemaV1.extends( + {}, + { unknowns: 'ignore' } + ), + create: rawApiKeyPendingInvalidationSchemaV1, + }, + }, +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/model_versions/index.ts b/x-pack/plugins/alerting/server/saved_objects/model_versions/index.ts index 89c4f3a3cd2bb..5c9a33b3b1714 100644 --- a/x-pack/plugins/alerting/server/saved_objects/model_versions/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/model_versions/index.ts @@ -6,4 +6,7 @@ */ export { adHocRunParamsModelVersions } from './ad_hoc_run_params_model_versions'; +export { apiKeyPendingInvalidationModelVersions } from './api_key_pending_invalidation_model_versions'; +export { maintenanceWindowModelVersions } from './maintenance_window_model_versions'; export { ruleModelVersions } from './rule_model_versions'; +export { rulesSettingsModelVersions } from './rules_settings_model_versions'; diff --git a/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts b/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts new file mode 100644 index 0000000000000..dbfda11dc85fc --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawMaintenanceWindowSchemaV1 } from '../schemas/raw_maintenance_window'; + +export const maintenanceWindowModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawMaintenanceWindowSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawMaintenanceWindowSchemaV1, + }, + }, +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/model_versions/rules_settings_model_versions.ts b/x-pack/plugins/alerting/server/saved_objects/model_versions/rules_settings_model_versions.ts new file mode 100644 index 0000000000000..323238c43c01c --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/model_versions/rules_settings_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawRulesSettingsSchemaV1 } from '../schemas/raw_rules_settings'; + +export const rulesSettingsModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawRulesSettingsSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawRulesSettingsSchemaV1, + }, + }, +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts new file mode 100644 index 0000000000000..585c0601eb2a3 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawApiKeyPendingInvalidationSchema as rawApiKeyPendingInvalidationSchemaV1 } from './v1'; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v1.ts new file mode 100644 index 0000000000000..814b8bd099cd9 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_api_key_pending_invalidation/v1.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawApiKeyPendingInvalidationSchema = schema.object({ + apiKeyId: schema.string(), + createdAt: schema.string(), +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/index.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/index.ts new file mode 100644 index 0000000000000..54ad09f251591 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawMaintenanceWindowSchema as rawMaintenanceWindowSchemaV1 } from './v1'; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/v1.ts new file mode 100644 index 0000000000000..66c5c432bb370 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_maintenance_window/v1.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { FilterStateStore } from '@kbn/es-query'; + +export const alertsFilterQuerySchema = schema.object({ + kql: schema.string(), + filters: schema.arrayOf( + schema.object({ + query: schema.maybe(schema.recordOf(schema.string(), schema.any())), + meta: schema.recordOf(schema.string(), schema.any()), + $state: schema.maybe( + schema.object({ + store: schema.oneOf([ + schema.literal(FilterStateStore.APP_STATE), + schema.literal(FilterStateStore.GLOBAL_STATE), + ]), + }) + ), + }) + ), + dsl: schema.maybe(schema.string()), +}); + +const rRuleSchema = schema.object({ + dtstart: schema.string(), + tzid: schema.string(), + freq: schema.maybe( + schema.oneOf([ + schema.literal(0), + schema.literal(1), + schema.literal(2), + schema.literal(3), + schema.literal(4), + schema.literal(5), + schema.literal(6), + ]) + ), + until: schema.maybe(schema.string()), + count: schema.maybe(schema.number()), + interval: schema.maybe(schema.number()), + wkst: schema.maybe( + schema.oneOf([ + schema.literal('MO'), + schema.literal('TU'), + schema.literal('WE'), + schema.literal('TH'), + schema.literal('FR'), + schema.literal('SA'), + schema.literal('SU'), + ]) + ), + byweekday: schema.maybe(schema.arrayOf(schema.oneOf([schema.string(), schema.number()]))), + bymonth: schema.maybe(schema.number()), + bysetpos: schema.maybe(schema.number()), + bymonthday: schema.maybe(schema.number()), + byyearday: schema.maybe(schema.number()), + byweekno: schema.maybe(schema.number()), + byhour: schema.maybe(schema.number()), + byminute: schema.maybe(schema.number()), + bysecond: schema.maybe(schema.number()), +}); + +const rawMaintenanceWindowEventsSchema = schema.object({ + gte: schema.string(), + lte: schema.string(), +}); + +export const rawMaintenanceWindowSchema = schema.object({ + categoryIds: schema.maybe(schema.nullable(schema.arrayOf(schema.string()))), + createdAt: schema.string(), + createdBy: schema.nullable(schema.string()), + duration: schema.number(), + enabled: schema.boolean(), + events: schema.arrayOf(rawMaintenanceWindowEventsSchema), + expirationDate: schema.string(), + rRule: rRuleSchema, + scopedQuery: schema.maybe(schema.nullable(alertsFilterQuerySchema)), + title: schema.string(), + updatedAt: schema.string(), + updatedBy: schema.nullable(schema.string()), +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/index.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/index.ts new file mode 100644 index 0000000000000..293dccfcddf63 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawRulesSettingsSchema as rawRulesSettingsSchemaV1 } from './v1'; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/v1.ts new file mode 100644 index 0000000000000..1e2aa60fca672 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rules_settings/v1.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawRulesSettingsSchema = schema.object({ + flapping: schema.maybe( + schema.object({ + createdAt: schema.string(), + createdBy: schema.nullable(schema.string()), + enabled: schema.boolean(), + lookBackWindow: schema.number(), + statusChangeThreshold: schema.number(), + updatedAt: schema.string(), + updatedBy: schema.nullable(schema.string()), + }) + ), + queryDelay: schema.maybe( + schema.object({ + createdAt: schema.string(), + createdBy: schema.nullable(schema.string()), + delay: schema.number(), + updatedAt: schema.string(), + updatedBy: schema.nullable(schema.string()), + }) + ), +}); From 40bfd12cc55ebfb1641ef21133fb009c23b0106f Mon Sep 17 00:00:00 2001 From: Mark Hopkin <mark.hopkin@elastic.co> Date: Tue, 15 Oct 2024 23:27:50 +0100 Subject: [PATCH 21/31] [Entity Analytics] Allow task status to be "claiming" in disable/enable test (#196172) ## Summary Closes https://github.com/elastic/kibana/issues/196166 The test is checking that when we disable the risk engine, the risk ewngine task is registered but not actively running. This check originally checked if the task status was "idle". We have had a failure where the task status is "claiming", reading the docs about this task status (below) this is also an acceptable "non-running" status ``` // idle: Task Instance isn't being worked on // claiming: A Kibana instance has claimed ownership but hasn't started running // the Task Instance yet ``` --- .../init_and_status_apis.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts index 19a9bb85326fa..3224caa24d5e2 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts @@ -19,6 +19,10 @@ import { } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +const expectTaskIsNotRunning = (taskStatus?: string) => { + expect(['idle', 'claiming']).contain(taskStatus); +}; + export default ({ getService }: FtrProviderContext) => { const es = getService('es'); const supertest = getService('supertest'); @@ -355,7 +359,7 @@ export default ({ getService }: FtrProviderContext) => { expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); expect(status2.body.risk_engine_task_status?.runAt).to.be.a('string'); - expect(status2.body.risk_engine_task_status?.status).to.be('idle'); + expectTaskIsNotRunning(status2.body.risk_engine_task_status?.status); expect(status2.body.risk_engine_task_status?.startedAt).to.be(undefined); await riskEngineRoutes.disable(); @@ -373,7 +377,7 @@ export default ({ getService }: FtrProviderContext) => { expect(status4.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); expect(status4.body.risk_engine_task_status?.runAt).to.be.a('string'); - expect(status4.body.risk_engine_task_status?.status).to.be('idle'); + expectTaskIsNotRunning(status4.body.risk_engine_task_status?.status); expect(status4.body.risk_engine_task_status?.startedAt).to.be(undefined); }); @@ -394,7 +398,7 @@ export default ({ getService }: FtrProviderContext) => { expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); expect(status2.body.risk_engine_task_status?.runAt).to.be.a('string'); - expect(status2.body.risk_engine_task_status?.status).to.be('idle'); + expectTaskIsNotRunning(status2.body.risk_engine_task_status?.status); expect(status2.body.risk_engine_task_status?.startedAt).to.be(undefined); }); }); From c448593d546f6200b0d2d35bce043bef521f41a6 Mon Sep 17 00:00:00 2001 From: Karen Grigoryan <karen.grigoryan@elastic.co> Date: Wed, 16 Oct 2024 01:18:50 +0200 Subject: [PATCH 22/31] [Security Solution][DQD] Add historical results tour guide (#196127) addresses #195971 This PR adds missing new historical results feature tour guide. ## Tour guide features: - ability to maintain visual presence while collapsing accordions in list-view - move from list-view to flyout view and back - seamlessly integrates with existing opening flyout and history tab functionality ## PR decisions with explanation: - data-tour-element has been introduced on select elements (like first actions of each first row) to avoid polluting every single element with data-test-subj. This way it's imho specific and semantically more clear what the elements are for. - early on I tried to control the anchoring with refs but some eui elements don't allow passing refs like EuiTab, so instead a more simpler and straightforward approach with dom selectors has been chosen - localStorage key name has been picked in accordance with other instances of usage `securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isActive` the name includes the full domain + the version when it's introduced. And since this tour step is a single step there is no need to stringify an object with `isTourActive` in and it's much simpler to just bake the activity state into the name and make the value just a boolean. ## UI Demo ### Anchor reposition demo (listview + flyout) https://github.com/user-attachments/assets/0f961c51-0e36-48ca-aab4-bef3b0d1269e ### List view tour guide try it + reload demo https://github.com/user-attachments/assets/ca1f5fda-ee02-4a48-827c-91df757a8ddf ### FlyOut Try It + reload demo https://github.com/user-attachments/assets/d0801ac3-1ed1-4e64-9d6b-3140b8402bdf ### Manual history tab selection path + reload demo https://github.com/user-attachments/assets/34dbb447-2fd6-4dc0-a4f5-682c9c65cc8b ### Manual open history view path + reload demo https://github.com/user-attachments/assets/945dd042-fc12-476e-8d23-f48c9ded9f65 ### Dismiss list view tour guide + reload demo https://github.com/user-attachments/assets/d20d1416-827f-46f2-9161-a3c0a8cbd932 ### Dismiss FlyOut tour guide + reload demo https://github.com/user-attachments/assets/8f085f59-20a9-49f0-b5b3-959c4719f5cb ### Serverless empty pattern handling + reposition demo https://github.com/user-attachments/assets/4af5939e-663c-4439-a3fc-deff2d4de7e4 --- .../indices_details/constants.ts | 9 + .../index.tsx | 28 + .../indices_details/index.test.tsx | 79 ++- .../indices_details/index.tsx | 48 +- .../indices_details/pattern/constants.ts | 2 + .../historical_results_tour/index.test.tsx | 105 ++++ .../pattern/historical_results_tour/index.tsx | 80 +++ .../historical_results_tour/translations.ts | 30 + .../indices_details/pattern/index.test.tsx | 549 +++++++++++++++++- .../indices_details/pattern/index.tsx | 85 ++- .../pattern/index_check_flyout/index.test.tsx | 185 +++++- .../pattern/index_check_flyout/index.tsx | 48 +- .../pattern/summary_table/index.tsx | 3 + .../summary_table/utils/columns.test.tsx | 54 ++ .../pattern/summary_table/utils/columns.tsx | 7 + .../mock_auditbeat_pattern_rollup.ts | 18 + 16 files changed, 1304 insertions(+), 26 deletions(-) create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts new file mode 100644 index 0000000000000..68c373217a4b4 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY = + 'securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isDismissed'; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx new file mode 100644 index 0000000000000..572bf7023dada --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY } from '../../constants'; + +export const useIsHistoricalResultsTourActive = () => { + const [isTourDismissed, setIsTourDismissed] = useLocalStorage<boolean>( + HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY, + false + ); + + const isTourActive = !isTourDismissed; + const setIsTourActive = useCallback( + (active: boolean) => { + setIsTourDismissed(!active); + }, + [setIsTourDismissed] + ); + + return [isTourActive, setIsTourActive] as const; +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx index d5aaa1eea19ae..b3d296c5a30db 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx @@ -6,12 +6,15 @@ */ import numeral from '@elastic/numeral'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; import { EMPTY_STAT } from '../../constants'; import { alertIndexWithAllResults } from '../../mock/pattern_rollup/mock_alerts_pattern_rollup'; -import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { + auditbeatWithAllResults, + emptyAuditbeatPatternRollup, +} from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; import { packetbeatNoResults } from '../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; import { TestDataQualityProviders, @@ -19,6 +22,8 @@ import { } from '../../mock/test_providers/test_providers'; import { PatternRollup } from '../../types'; import { Props, IndicesDetails } from '.'; +import userEvent from '@testing-library/user-event'; +import { HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY } from './constants'; const defaultBytesFormat = '0,0.[0]b'; const formatBytes = (value: number | undefined) => @@ -29,15 +34,22 @@ const formatNumber = (value: number | undefined) => value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; const ilmPhases = ['hot', 'warm', 'unmanaged']; -const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; +const patterns = [ + 'test-empty-pattern-*', + '.alerts-security.alerts-default', + 'auditbeat-*', + 'packetbeat-*', +]; const patternRollups: Record<string, PatternRollup> = { + 'test-empty-pattern-*': { ...emptyAuditbeatPatternRollup, pattern: 'test-empty-pattern-*' }, '.alerts-security.alerts-default': alertIndexWithAllResults, 'auditbeat-*': auditbeatWithAllResults, 'packetbeat-*': packetbeatNoResults, }; const patternIndexNames: Record<string, string[]> = { + 'test-empty-pattern-*': [], 'auditbeat-*': [ '.ds-auditbeat-8.6.1-2023.02.07-000001', 'auditbeat-custom-empty-index-1', @@ -58,6 +70,7 @@ const defaultProps: Props = { describe('IndicesDetails', () => { beforeEach(async () => { jest.clearAllMocks(); + localStorage.removeItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY); render( <TestExternalProviders> @@ -74,10 +87,64 @@ describe('IndicesDetails', () => { }); describe('rendering patterns', () => { - patterns.forEach((pattern) => { - test(`it renders the ${pattern} pattern`, () => { - expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + test.each(patterns)('it renders the %s pattern', (pattern) => { + expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + }); + }); + + describe('tour', () => { + test('it renders the tour wrapping view history button of first row of first non-empty pattern', async () => { + const wrapper = await screen.findByTestId('historicalResultsTour'); + const button = within(wrapper).getByRole('button', { name: 'View history' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('data-tour-element', patterns[1]); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when the tour is dismissed', () => { + test('it hides the tour and persists in localStorage', async () => { + const wrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const button = within(wrapper).getByRole('button', { name: 'Close' }); + + await userEvent.click(button); + + await waitFor(() => expect(screen.queryByTestId('historicalResultsTour')).toBeNull()); + + expect(localStorage.getItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY)).toEqual( + 'true' + ); }); }); + + describe('when the first pattern is toggled', () => { + test('it renders the tour wrapping view history button of first row of second non-empty pattern', async () => { + const firstNonEmptyPatternAccordionWrapper = await screen.findByTestId( + `${patterns[1]}PatternPanel` + ); + const accordionToggle = within(firstNonEmptyPatternAccordionWrapper).getByRole('button', { + name: /Pass/, + }); + await userEvent.click(accordionToggle); + + const secondPatternAccordionWrapper = screen.getByTestId(`${patterns[2]}PatternPanel`); + const historicalResultsWrapper = await within(secondPatternAccordionWrapper).findByTestId( + 'historicalResultsTour' + ); + const button = within(historicalResultsWrapper).getByRole('button', { + name: 'View history', + }); + expect(button).toHaveAttribute('data-tour-element', patterns[2]); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }, 10000); + }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx index fd565d8fc7637..b3b708291a983 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx @@ -6,13 +6,14 @@ */ import { EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import styled from 'styled-components'; import { useResultsRollupContext } from '../../contexts/results_rollup_context'; import { Pattern } from './pattern'; import { SelectedIndex } from '../../types'; import { useDataQualityContext } from '../../data_quality_context'; +import { useIsHistoricalResultsTourActive } from './hooks/use_is_historical_results_tour_active'; const StyledPatternWrapperFlexItem = styled(EuiFlexItem)` margin-bottom: ${({ theme }) => theme.eui.euiSize}; @@ -34,6 +35,41 @@ const IndicesDetailsComponent: React.FC<Props> = ({ const { patternRollups, patternIndexNames } = useResultsRollupContext(); const { patterns } = useDataQualityContext(); + const [isTourActive, setIsTourActive] = useIsHistoricalResultsTourActive(); + + const handleDismissTour = useCallback(() => { + setIsTourActive(false); + }, [setIsTourActive]); + + const [openPatterns, setOpenPatterns] = useState< + Array<{ name: string; isOpen: boolean; isEmpty: boolean }> + >(() => { + return patterns.map((pattern) => ({ name: pattern, isOpen: true, isEmpty: false })); + }); + + const handleAccordionToggle = useCallback( + (patternName: string, isOpen: boolean, isEmpty: boolean) => { + setOpenPatterns((prevOpenPatterns) => { + return prevOpenPatterns.map((p) => + p.name === patternName ? { ...p, isOpen, isEmpty } : p + ); + }); + }, + [] + ); + + const firstOpenNonEmptyPattern = openPatterns.find((pattern) => { + return pattern.isOpen && !pattern.isEmpty; + })?.name; + + const [openPatternsUpdatedAt, setOpenPatternsUpdatedAt] = useState<number>(Date.now()); + + useEffect(() => { + if (firstOpenNonEmptyPattern) { + setOpenPatternsUpdatedAt(Date.now()); + } + }, [openPatterns, firstOpenNonEmptyPattern]); + return ( <div data-test-subj="indicesDetails"> {patterns.map((pattern) => ( @@ -44,6 +80,16 @@ const IndicesDetailsComponent: React.FC<Props> = ({ patternRollup={patternRollups[pattern]} chartSelectedIndex={chartSelectedIndex} setChartSelectedIndex={setChartSelectedIndex} + isTourActive={isTourActive} + isFirstOpenNonEmptyPattern={pattern === firstOpenNonEmptyPattern} + onAccordionToggle={handleAccordionToggle} + onDismissTour={handleDismissTour} + // TODO: remove this hack when EUI popover is fixed + // https://github.com/elastic/eui/issues/5226 + // + // this information is used to force the tour guide popover to reposition + // when surrounding accordions get toggled and affect the layout + {...(pattern === firstOpenNonEmptyPattern && { openPatternsUpdatedAt })} /> </StyledPatternWrapperFlexItem> ))} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts index 4bab5938cf98b..a02eccb3e81a4 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts @@ -9,3 +9,5 @@ export const MIN_PAGE_SIZE = 10; export const HISTORY_TAB_ID = 'history'; export const LATEST_CHECK_TAB_ID = 'latest_check'; + +export const HISTORICAL_RESULTS_TOUR_SELECTOR_KEY = 'data-tour-element'; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx new file mode 100644 index 0000000000000..53f2e059072c8 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../constants'; +import { HistoricalResultsTour } from '.'; +import { INTRODUCING_DATA_QUALITY_HISTORY, VIEW_PAST_RESULTS } from './translations'; + +const anchorSelectorValue = 'test-anchor'; + +describe('HistoricalResultsTour', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('given no anchor element', () => { + it('does not render the tour step', () => { + render( + <HistoricalResultsTour + anchorSelectorValue={anchorSelectorValue} + onTryIt={jest.fn()} + isOpen={true} + onDismissTour={jest.fn()} + /> + ); + + expect(screen.queryByText(INTRODUCING_DATA_QUALITY_HISTORY)).not.toBeInTheDocument(); + }); + }); + + describe('given an anchor element', () => { + beforeEach(() => { + // eslint-disable-next-line no-unsanitized/property + document.body.innerHTML = `<div ${HISTORICAL_RESULTS_TOUR_SELECTOR_KEY}="${anchorSelectorValue}"></div>`; + }); + + describe('when isOpen is true', () => { + const onTryIt = jest.fn(); + const onDismissTour = jest.fn(); + beforeEach(() => { + render( + <HistoricalResultsTour + anchorSelectorValue={anchorSelectorValue} + onTryIt={onTryIt} + isOpen={true} + onDismissTour={onDismissTour} + /> + ); + }); + it('renders the tour step', async () => { + expect( + await screen.findByRole('dialog', { name: INTRODUCING_DATA_QUALITY_HISTORY }) + ).toBeInTheDocument(); + expect(screen.getByText(INTRODUCING_DATA_QUALITY_HISTORY)).toBeInTheDocument(); + expect(screen.getByText(VIEW_PAST_RESULTS)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Try It/i })).toBeInTheDocument(); + + const historicalResultsTour = screen.getByTestId('historicalResultsTour'); + expect(historicalResultsTour.querySelector('[data-tour-element]')).toHaveAttribute( + 'data-tour-element', + anchorSelectorValue + ); + }); + + describe('when the close button is clicked', () => { + it('calls dismissTour', async () => { + await userEvent.click(await screen.findByRole('button', { name: /Close/i })); + expect(onDismissTour).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the try it button is clicked', () => { + it('calls onTryIt', async () => { + await userEvent.click(await screen.findByRole('button', { name: /Try It/i })); + expect(onTryIt).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when isOpen is false', () => { + it('does not render the tour step', async () => { + render( + <HistoricalResultsTour + anchorSelectorValue={anchorSelectorValue} + onTryIt={jest.fn()} + isOpen={false} + onDismissTour={jest.fn()} + /> + ); + + await waitFor(() => + expect(screen.queryByText(INTRODUCING_DATA_QUALITY_HISTORY)).not.toBeInTheDocument() + ); + }); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx new file mode 100644 index 0000000000000..5e63379d17375 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiText, EuiTourStep } from '@elastic/eui'; +import styled from 'styled-components'; + +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../constants'; +import { CLOSE, INTRODUCING_DATA_QUALITY_HISTORY, TRY_IT, VIEW_PAST_RESULTS } from './translations'; + +export interface Props { + anchorSelectorValue: string; + isOpen: boolean; + onTryIt: () => void; + onDismissTour: () => void; + zIndex?: number; +} + +const StyledText = styled(EuiText)` + margin-block-start: -10px; +`; + +export const HistoricalResultsTour: FC<Props> = ({ + anchorSelectorValue, + onTryIt, + isOpen, + onDismissTour, + zIndex, +}) => { + const [anchorElement, setAnchorElement] = useState<HTMLElement>(); + + useEffect(() => { + const element = document.querySelector<HTMLElement>( + `[${HISTORICAL_RESULTS_TOUR_SELECTOR_KEY}="${anchorSelectorValue}"]` + ); + + if (!element) { + return; + } + + setAnchorElement(element); + }, [anchorSelectorValue]); + + if (!isOpen || !anchorElement) { + return null; + } + + return ( + <EuiTourStep + content={ + <StyledText size="s"> + <p>{VIEW_PAST_RESULTS}</p> + </StyledText> + } + data-test-subj="historicalResultsTour" + isStepOpen={isOpen} + minWidth={283} + onFinish={onDismissTour} + step={1} + stepsTotal={1} + title={INTRODUCING_DATA_QUALITY_HISTORY} + anchorPosition="rightUp" + repositionOnScroll + anchor={anchorElement} + zIndex={zIndex} + footerAction={[ + <EuiButtonEmpty size="xs" color="text" onClick={onDismissTour}> + {CLOSE} + </EuiButtonEmpty>, + <EuiButton color="success" size="s" onClick={onTryIt}> + {TRY_IT} + </EuiButton>, + ]} + /> + ); +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts new file mode 100644 index 0000000000000..d8f81aa288baa --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLOSE = i18n.translate('securitySolutionPackages.ecsDataQualityDashboard.close', { + defaultMessage: 'Close', +}); + +export const TRY_IT = i18n.translate('securitySolutionPackages.ecsDataQualityDashboard.tryIt', { + defaultMessage: 'Try it', +}); + +export const INTRODUCING_DATA_QUALITY_HISTORY = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.introducingDataQualityHistory', + { + defaultMessage: 'Introducing data quality history', + } +); + +export const VIEW_PAST_RESULTS = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.viewPastResults', + { + defaultMessage: 'View past results', + } +); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx index a165378df80ed..eb6116c3276f9 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx @@ -6,19 +6,23 @@ */ import React from 'react'; -import { act, render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import { TestDataQualityProviders, TestExternalProviders, } from '../../../mock/test_providers/test_providers'; import { Pattern } from '.'; -import { auditbeatWithAllResults } from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { + auditbeatWithAllResults, + emptyAuditbeatPatternRollup, +} from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; import { useIlmExplain } from './hooks/use_ilm_explain'; import { useStats } from './hooks/use_stats'; import { ERROR_LOADING_METADATA_TITLE, LOADING_STATS } from './translations'; import { useHistoricalResults } from './hooks/use_historical_results'; import { getHistoricalResultStub } from '../../../stub/get_historical_result_stub'; +import userEvent from '@testing-library/user-event'; const pattern = 'auditbeat-*'; @@ -81,6 +85,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -95,6 +103,157 @@ describe('pattern', () => { expect(screen.getByTestId('summaryTable')).toBeInTheDocument(); }); + describe('onAccordionToggle', () => { + describe('by default', () => { + describe('when no summary table items are available', () => { + it('invokes the onAccordionToggle function with the pattern name, isOpen as true and isEmpty as true', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: null, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: null, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={emptyAuditbeatPatternRollup} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={[]} + pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={onAccordionToggle} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const accordionToggle = await screen.findByRole('button', { + name: 'auditbeat-* Incompatible fields 0 Indices checked 0 Indices 0 Size 0B Docs 0', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, true); + }); + }); + + describe('when summary table items are available', () => { + it('invokes the onAccordionToggle function with the pattern name, isOpen as true and isEmpty as false', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={onAccordionToggle} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + }); + }); + }); + + describe('when the accordion is toggled', () => { + it('calls the onAccordionToggle function with current open state and current empty state', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={onAccordionToggle} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenLastCalledWith(pattern, false, false); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(3); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + }); + }); + }); + describe('remote clusters callout', () => { describe('when the pattern includes a colon', () => { it('it renders the remote clusters callout', () => { @@ -107,6 +266,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={undefined} pattern={'remote:*'} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -127,6 +290,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={undefined} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -155,6 +322,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -182,6 +353,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -215,6 +390,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -248,6 +427,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -292,6 +475,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -306,7 +493,7 @@ describe('pattern', () => { name: 'Check now', }); - await act(async () => checkNowButton.click()); + await userEvent.click(checkNowButton); // assert expect(checkIndex).toHaveBeenCalledTimes(1); @@ -370,6 +557,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -384,7 +575,7 @@ describe('pattern', () => { name: 'View history', }); - await act(async () => viewHistoryButton.click()); + await userEvent.click(viewHistoryButton); // assert expect(fetchHistoricalResults).toHaveBeenCalledTimes(1); @@ -444,6 +635,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -458,11 +653,11 @@ describe('pattern', () => { name: 'View history', }); - await act(async () => viewHistoryButton.click()); + await userEvent.click(viewHistoryButton); const closeButton = screen.getByRole('button', { name: 'Close this dialog' }); - await act(async () => closeButton.click()); + await userEvent.click(closeButton); // assert expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); @@ -504,6 +699,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> </TestDataQualityProviders> </TestExternalProviders> @@ -533,4 +732,342 @@ describe('pattern', () => { }); }); }); + + describe('Tour', () => { + describe('when isTourActive and isFirstOpenNonEmptyPattern', () => { + it('renders the tour near the first row history view button', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const rows = screen.getAllByRole('row'); + // skipping the first row which is the header + const firstBodyRow = within(rows[1]); + + const tourWrapper = await firstBodyRow.findByTestId('historicalResultsTour'); + + expect( + within(tourWrapper).getByRole('button', { name: 'View history' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when accordion is collapsed', () => { + it('hides the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + expect(await screen.findByTestId('historicalResultsTour')).toBeInTheDocument(); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + await userEvent.click(accordionToggle); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }, 10000); + }); + + describe('when the tour close button is clicked', () => { + it('invokes onDismissTour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={onDismissTour} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const tourDialog = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const closeButton = within(tourDialog).getByRole('button', { name: 'Close' }); + + await userEvent.click(closeButton); + + expect(onDismissTour).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the tour tryIt action is clicked', () => { + it('opens the flyout with history tab and invokes onDismissTour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={onDismissTour} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const tourDialog = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const tryItButton = within(tourDialog).getByRole('button', { name: 'Try it' }); + + await userEvent.click(tryItButton); + + expect(onDismissTour).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + }); + + describe('when latest latest check flyout tab is opened', () => { + it('hides the tour in listview and shows in flyout', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={onDismissTour} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const rows = screen.getAllByRole('row'); + // skipping the first row which is the header + const firstBodyRow = within(rows[1]); + + expect(await firstBodyRow.findByTestId('historicalResultsTour')).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + + const checkNowButton = firstBodyRow.getByRole('button', { + name: 'Check now', + }); + await userEvent.click(checkNowButton); + + expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + + expect(firstBodyRow.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + + const tabWrapper = await screen.findByRole('tab', { name: 'History' }); + await waitFor(() => + expect( + tabWrapper.closest('[data-test-subj="historicalResultsTour"]') + ).toBeInTheDocument() + ); + + expect(onDismissTour).not.toHaveBeenCalled(); + }, 10000); + }); + }); + + describe('when not isFirstOpenNonEmptyPattern', () => { + it('does not render the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={true} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }); + }); + + describe('when not isTourActive', () => { + it('does not render the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + <TestExternalProviders> + <TestDataQualityProviders> + <Pattern + patternRollup={auditbeatWithAllResults} + chartSelectedIndex={null} + setChartSelectedIndex={jest.fn()} + indexNames={Object.keys(auditbeatWithAllResults.stats!)} + pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={true} + onAccordionToggle={jest.fn()} + /> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx index 30c4aa8755a9c..a51f521eca169 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx @@ -35,6 +35,7 @@ import { getPageIndex } from './utils/get_page_index'; import { useAbortControllerRef } from '../../../hooks/use_abort_controller_ref'; import { useHistoricalResults } from './hooks/use_historical_results'; import { HistoricalResultsContext } from './contexts/historical_results_context'; +import { HistoricalResultsTour } from './historical_results_tour'; const EMPTY_INDEX_NAMES: string[] = []; @@ -44,6 +45,11 @@ interface Props { patternRollup: PatternRollup | undefined; chartSelectedIndex: SelectedIndex | null; setChartSelectedIndex: (selectedIndex: SelectedIndex | null) => void; + isTourActive: boolean; + isFirstOpenNonEmptyPattern: boolean; + onAccordionToggle: (patternName: string, isOpen: boolean, isEmpty: boolean) => void; + onDismissTour: () => void; + openPatternsUpdatedAt?: number; } const PatternComponent: React.FC<Props> = ({ @@ -52,6 +58,11 @@ const PatternComponent: React.FC<Props> = ({ patternRollup, chartSelectedIndex, setChartSelectedIndex, + isTourActive, + isFirstOpenNonEmptyPattern, + onAccordionToggle, + onDismissTour, + openPatternsUpdatedAt, }) => { const { historicalResultsState, fetchHistoricalResults } = useHistoricalResults(); const historicalResultsContextValue = useMemo( @@ -124,6 +135,35 @@ const PatternComponent: React.FC<Props> = ({ ] ); + const [isAccordionOpen, setIsAccordionOpen] = useState(true); + + const isAccordionOpenRef = useRef(isAccordionOpen); + useEffect(() => { + isAccordionOpenRef.current = isAccordionOpen; + }, [isAccordionOpen]); + + useEffect(() => { + // this use effect syncs isEmpty state with the parent component + // + // we do not add isAccordionOpen to the dependency array because + // it is already handled by handleAccordionToggle + // so we don't want to additionally trigger this useEffect when isAccordionOpen changes + // because it's confusing and unnecessary + // that's why we use ref here to keep separation of concerns + onAccordionToggle(pattern, isAccordionOpenRef.current, items.length === 0); + }, [items.length, onAccordionToggle, pattern]); + + const handleAccordionToggle = useCallback( + (isOpen: boolean) => { + const isEmpty = items.length === 0; + setIsAccordionOpen(isOpen); + onAccordionToggle(pattern, isOpen, isEmpty); + }, + [items.length, onAccordionToggle, pattern] + ); + + const firstRow = items[0]; + const handleFlyoutClose = useCallback(() => { setExpandedIndexName(null); }, []); @@ -153,6 +193,9 @@ const PatternComponent: React.FC<Props> = ({ const handleFlyoutViewCheckHistoryAction = useCallback( (indexName: string) => { + if (isTourActive) { + onDismissTour(); + } fetchHistoricalResults({ abortController: flyoutViewCheckHistoryAbortControllerRef.current, indexName, @@ -160,9 +203,16 @@ const PatternComponent: React.FC<Props> = ({ setExpandedIndexName(indexName); setInitialFlyoutTabId(HISTORY_TAB_ID); }, - [fetchHistoricalResults, flyoutViewCheckHistoryAbortControllerRef] + [fetchHistoricalResults, flyoutViewCheckHistoryAbortControllerRef, isTourActive, onDismissTour] ); + const handleOpenFlyoutHistoryTab = useCallback(() => { + const firstItemIndexName = firstRow?.indexName; + if (firstItemIndexName) { + handleFlyoutViewCheckHistoryAction(firstItemIndexName); + } + }, [firstRow?.indexName, handleFlyoutViewCheckHistoryAction]); + useEffect(() => { const newIndexNames = getIndexNames({ stats, ilmExplain, ilmPhases, isILMAvailable }); const newDocsCount = getPatternDocsCount({ indexNames: newIndexNames, stats }); @@ -270,7 +320,8 @@ const PatternComponent: React.FC<Props> = ({ <HistoricalResultsContext.Provider value={historicalResultsContextValue}> <PatternAccordion id={patternComponentAccordionId} - initialIsOpen={true} + forceState={isAccordionOpen ? 'open' : 'closed'} + onToggle={handleAccordionToggle} buttonElement="div" buttonContent={ <PatternSummary @@ -308,6 +359,34 @@ const PatternComponent: React.FC<Props> = ({ {!loading && error == null && ( <div ref={containerRef}> + <HistoricalResultsTour + // this is a hack to force popover anchor position recalculation + // when the first open non-empty pattern layout changes due to other + // patterns being opened/closed + // It's a bug on Eui side + // + // TODO: remove this hack when EUI popover is fixed + // https://github.com/elastic/eui/issues/5226 + {...(isFirstOpenNonEmptyPattern && { key: openPatternsUpdatedAt })} + anchorSelectorValue={pattern} + onTryIt={handleOpenFlyoutHistoryTab} + isOpen={ + isTourActive && + !isFlyoutVisible && + isFirstOpenNonEmptyPattern && + isAccordionOpen + } + onDismissTour={onDismissTour} + // Only set zIndex when the tour is in list view (not in flyout) + // + // 1 less than the z-index of the left navigation + // 5 less than the z-index of the timeline + // + // + // TODO this hack should be removed when we properly set z-indexes + // in the timeline and left navigation + zIndex={998} + /> <SummaryTable getTableColumns={getSummaryTableColumns} items={items} @@ -334,6 +413,8 @@ const PatternComponent: React.FC<Props> = ({ ilmExplain={ilmExplain} stats={stats} onClose={handleFlyoutClose} + onDismissTour={onDismissTour} + isTourActive={isTourActive} /> ) : null} </HistoricalResultsContext.Provider> diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx index 7b63f712a99da..e73fd4c2d610d 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IndexCheckFlyout } from '.'; @@ -41,6 +41,8 @@ describe('IndexCheckFlyout', () => { pattern="auditbeat-*" patternRollup={auditbeatWithAllResults} stats={mockStats} + onDismissTour={jest.fn()} + isTourActive={false} /> </TestHistoricalResultsProvider> </TestDataQualityProviders> @@ -97,6 +99,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + isTourActive={false} + onDismissTour={jest.fn()} /> </TestHistoricalResultsProvider> </TestDataQualityProviders> @@ -129,6 +133,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + isTourActive={false} + onDismissTour={jest.fn()} /> </TestHistoricalResultsProvider> </TestDataQualityProviders> @@ -175,6 +181,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + onDismissTour={jest.fn()} + isTourActive={false} /> </TestHistoricalResultsProvider> </TestDataQualityProviders> @@ -207,4 +215,179 @@ describe('IndexCheckFlyout', () => { expect(screen.getByTestId('historicalResults')).toBeInTheDocument(); }); }); + + describe('Tour guide', () => { + describe('when in Latest Check tab and isTourActive', () => { + it('should render the tour guide near history tab with proper data-tour-element attribute', async () => { + const pattern = 'auditbeat-*'; + render( + <TestExternalProviders> + <TestDataQualityProviders> + <TestHistoricalResultsProvider> + <IndexCheckFlyout + ilmExplain={mockIlmExplain} + indexName="auditbeat-custom-index-1" + onClose={jest.fn()} + pattern={pattern} + patternRollup={auditbeatWithAllResults} + stats={mockStats} + initialSelectedTabId="latest_check" + onDismissTour={jest.fn()} + isTourActive={true} + /> + </TestHistoricalResultsProvider> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const historyTab = screen.getByRole('tab', { name: 'History' }); + const latestCheckTab = screen.getByRole('tab', { name: 'Latest Check' }); + + expect(historyTab).toHaveAttribute('data-tour-element', `${pattern}-history-tab`); + expect(latestCheckTab).not.toHaveAttribute('data-tour-element', `${pattern}-history-tab`); + await waitFor(() => + expect(historyTab.closest('[data-test-subj="historicalResultsTour"]')).toBeInTheDocument() + ); + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when the tour close button is clicked', () => { + it('should invoke the dismiss tour callback', async () => { + const onDismissTour = jest.fn(); + render( + <TestExternalProviders> + <TestDataQualityProviders> + <TestHistoricalResultsProvider> + <IndexCheckFlyout + ilmExplain={mockIlmExplain} + indexName="auditbeat-custom-index-1" + onClose={jest.fn()} + pattern="auditbeat-*" + patternRollup={auditbeatWithAllResults} + stats={mockStats} + initialSelectedTabId="latest_check" + onDismissTour={onDismissTour} + isTourActive={true} + /> + </TestHistoricalResultsProvider> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const dialogWrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const closeButton = within(dialogWrapper).getByRole('button', { name: 'Close' }); + await userEvent.click(closeButton); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + + describe('when the tour TryIt button is clicked', () => { + it('should switch to history tab and invoke onDismissTour', async () => { + const onDismissTour = jest.fn(); + render( + <TestExternalProviders> + <TestDataQualityProviders> + <TestHistoricalResultsProvider> + <IndexCheckFlyout + ilmExplain={mockIlmExplain} + indexName="auditbeat-custom-index-1" + onClose={jest.fn()} + pattern="auditbeat-*" + patternRollup={auditbeatWithAllResults} + stats={mockStats} + initialSelectedTabId="latest_check" + onDismissTour={onDismissTour} + isTourActive={true} + /> + </TestHistoricalResultsProvider> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const dialogWrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const tryItButton = within(dialogWrapper).getByRole('button', { name: 'Try it' }); + await userEvent.click(tryItButton); + + expect(onDismissTour).toHaveBeenCalled(); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + + describe('when manually switching to history tab', () => { + it('should invoke onDismissTour', async () => { + const onDismissTour = jest.fn(); + render( + <TestExternalProviders> + <TestDataQualityProviders> + <TestHistoricalResultsProvider> + <IndexCheckFlyout + ilmExplain={mockIlmExplain} + indexName="auditbeat-custom-index-1" + onClose={jest.fn()} + pattern="auditbeat-*" + patternRollup={auditbeatWithAllResults} + stats={mockStats} + initialSelectedTabId="latest_check" + onDismissTour={onDismissTour} + isTourActive={true} + /> + </TestHistoricalResultsProvider> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + const historyTab = screen.getByRole('tab', { name: 'History' }); + await userEvent.click(historyTab); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + }); + + describe('when not isTourActive', () => { + it('should not render the tour guide', async () => { + render( + <TestExternalProviders> + <TestDataQualityProviders> + <TestHistoricalResultsProvider> + <IndexCheckFlyout + ilmExplain={mockIlmExplain} + indexName="auditbeat-custom-index-1" + onClose={jest.fn()} + pattern="auditbeat-*" + patternRollup={auditbeatWithAllResults} + stats={mockStats} + initialSelectedTabId="latest_check" + onDismissTour={jest.fn()} + isTourActive={false} + /> + </TestHistoricalResultsProvider> + </TestDataQualityProviders> + </TestExternalProviders> + ); + + await waitFor(() => + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument() + ); + + expect( + screen.queryByRole('dialog', { name: 'Introducing data quality history' }) + ).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx index f298af704307d..b6dcf850d15b0 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx @@ -36,8 +36,13 @@ import { HistoricalResults } from './historical_results'; import { useHistoricalResultsContext } from '../contexts/historical_results_context'; import { getFormattedCheckTime } from './utils/get_formatted_check_time'; import { CHECK_NOW } from '../translations'; -import { HISTORY_TAB_ID, LATEST_CHECK_TAB_ID } from '../constants'; +import { + HISTORICAL_RESULTS_TOUR_SELECTOR_KEY, + HISTORY_TAB_ID, + LATEST_CHECK_TAB_ID, +} from '../constants'; import { IndexCheckFlyoutTabId } from './types'; +import { HistoricalResultsTour } from '../historical_results_tour'; export interface Props { ilmExplain: Record<string, IlmExplainLifecycleLifecycleExplain> | null; @@ -47,6 +52,8 @@ export interface Props { stats: Record<string, MeteringStatsIndex> | null; onClose: () => void; initialSelectedTabId: IndexCheckFlyoutTabId; + onDismissTour: () => void; + isTourActive: boolean; } const tabs = [ @@ -68,6 +75,8 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ patternRollup, stats, onClose, + onDismissTour, + isTourActive, }) => { const didSwitchToLatestTabOnceRef = useRef(false); const { fetchHistoricalResults } = useHistoricalResultsContext(); @@ -90,12 +99,15 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ const handleTabClick = useCallback( (tabId: IndexCheckFlyoutTabId) => { + setSelectedTabId(tabId); if (tabId === HISTORY_TAB_ID) { + if (isTourActive) { + onDismissTour(); + } fetchHistoricalResults({ abortController: fetchHistoricalResultsAbortControllerRef.current, indexName, }); - setSelectedTabId(tabId); } if (tabId === LATEST_CHECK_TAB_ID) { @@ -110,7 +122,6 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ formatNumber, }); } - setSelectedTabId(tabId); } }, [ @@ -122,6 +133,8 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ formatNumber, httpFetch, indexName, + isTourActive, + onDismissTour, pattern, ] ); @@ -149,6 +162,10 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ selectedTabId, ]); + const handleSelectHistoryTab = useCallback(() => { + handleTabClick(HISTORY_TAB_ID); + }, [handleTabClick]); + const renderTabs = useMemo( () => tabs.map((tab, index) => { @@ -157,12 +174,15 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ onClick={() => handleTabClick(tab.id)} isSelected={tab.id === selectedTabId} key={index} + {...(tab.id === HISTORY_TAB_ID && { + [HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: `${pattern}-history-tab`, + })} > {tab.name} </EuiTab> ); }), - [handleTabClick, selectedTabId] + [handleTabClick, pattern, selectedTabId] ); return ( @@ -195,12 +215,20 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({ </EuiFlyoutHeader> <EuiFlyoutBody> {selectedTabId === LATEST_CHECK_TAB_ID ? ( - <LatestResults - indexName={indexName} - stats={stats} - ilmExplain={ilmExplain} - patternRollup={patternRollup} - /> + <> + <LatestResults + indexName={indexName} + stats={stats} + ilmExplain={ilmExplain} + patternRollup={patternRollup} + /> + <HistoricalResultsTour + anchorSelectorValue={`${pattern}-history-tab`} + onTryIt={handleSelectHistoryTab} + isOpen={isTourActive} + onDismissTour={onDismissTour} + /> + </> ) : ( <HistoricalResults indexName={indexName} /> )} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx index fa574362e7d9b..02298a5b7dd94 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx @@ -30,6 +30,7 @@ export interface Props { pattern: string; onCheckNowAction: (indexName: string) => void; onViewHistoryAction: (indexName: string) => void; + firstIndexName?: string; }) => Array<EuiBasicTableColumn<IndexSummaryTableItem>>; items: IndexSummaryTableItem[]; pageIndex: number; @@ -66,6 +67,7 @@ const SummaryTableComponent: React.FC<Props> = ({ pattern, onCheckNowAction, onViewHistoryAction, + firstIndexName: items[0]?.indexName, }), [ getTableColumns, @@ -75,6 +77,7 @@ const SummaryTableComponent: React.FC<Props> = ({ pattern, onCheckNowAction, onViewHistoryAction, + items, ] ); const getItemId = useCallback((item: IndexSummaryTableItem) => item.indexName, []); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx index eda93c45f3b4f..bffd0c7fb91de 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx @@ -197,6 +197,60 @@ describe('helpers', () => { expect(onViewHistoryAction).toBeCalledWith(indexSummaryTableItem.indexName); }); + + test('adds data-tour-element attribute to the first view history button', () => { + const pattern = 'auditbeat-*'; + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + isILMAvailable, + pattern, + onCheckNowAction: jest.fn(), + onViewHistoryAction: jest.fn(), + firstIndexName: indexName, + }); + + const expandActionRender = ( + (columns[0] as EuiTableActionsColumnType<IndexSummaryTableItem>) + .actions[1] as CustomItemAction<IndexSummaryTableItem> + ).render; + + render( + <TestExternalProviders> + {expandActionRender != null && expandActionRender(indexSummaryTableItem, true)} + </TestExternalProviders> + ); + + const button = screen.getByLabelText(VIEW_HISTORY); + expect(button).toHaveAttribute('data-tour-element', pattern); + }); + + test('doesn`t add data-tour-element attribute to non-first view history buttons', () => { + const pattern = 'auditbeat-*'; + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + isILMAvailable, + pattern, + onCheckNowAction: jest.fn(), + onViewHistoryAction: jest.fn(), + firstIndexName: 'another-index', + }); + + const expandActionRender = ( + (columns[0] as EuiTableActionsColumnType<IndexSummaryTableItem>) + .actions[1] as CustomItemAction<IndexSummaryTableItem> + ).render; + + render( + <TestExternalProviders> + {expandActionRender != null && expandActionRender(indexSummaryTableItem, true)} + </TestExternalProviders> + ); + + const button = screen.getByLabelText(VIEW_HISTORY); + expect(button).not.toHaveAttribute('data-tour-element'); + }); }); describe('incompatible render()', () => { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx index c930d47babc2e..832ba71d26af8 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx @@ -37,6 +37,7 @@ import { IndexResultBadge } from '../../index_result_badge'; import { Stat } from '../../../../../stat'; import { getIndexResultToolTip } from '../../utils/get_index_result_tooltip'; import { CHECK_NOW } from '../../translations'; +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../../constants'; const ProgressContainer = styled.div` width: 150px; @@ -102,6 +103,7 @@ export const getSummaryTableColumns = ({ pattern, onCheckNowAction, onViewHistoryAction, + firstIndexName, }: { formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; @@ -109,6 +111,7 @@ export const getSummaryTableColumns = ({ pattern: string; onCheckNowAction: (indexName: string) => void; onViewHistoryAction: (indexName: string) => void; + firstIndexName?: string; }): Array<EuiBasicTableColumn<IndexSummaryTableItem>> => [ { name: i18n.ACTIONS, @@ -132,12 +135,16 @@ export const getSummaryTableColumns = ({ { name: i18n.VIEW_HISTORY, render: (item) => { + const isFirstIndexName = firstIndexName === item.indexName; return ( <EuiToolTip content={i18n.VIEW_HISTORY}> <EuiButtonIcon iconType="clockCounter" aria-label={i18n.VIEW_HISTORY} onClick={() => onViewHistoryAction(item.indexName)} + {...(isFirstIndexName && { + [HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: pattern, + })} /> </EuiToolTip> ); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts index 6f3c7b008a5af..9d0e09ef57d96 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts @@ -166,3 +166,21 @@ export const auditbeatWithAllResults: PatternRollup = { }, }, }; + +export const emptyAuditbeatPatternRollup: PatternRollup = { + docsCount: 0, + error: null, + ilmExplain: {}, + ilmExplainPhaseCounts: { + hot: 0, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 0, + pattern: 'auditbeat-*', + results: {}, + sizeInBytes: 0, + stats: {}, +}; From dd25bf8807c3ff3982d455f070b6e6c65233662d Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Wed, 16 Oct 2024 01:40:19 +0200 Subject: [PATCH 23/31] Skip scheduling actions for the alerts without scheduledActions (#195948) Resolves: #190258 As a result of #190258, we have found out that the odd behaviour happens when an existing alert is pushed above the max alerts limit by a new alert. Scenario: 1. The rule type detects 4 alerts (`alert-1`, `alert-2`, `alert-3`, `alert-4`), But reports only the first 3 as the max alerts limit is 3. 2. `alert-2` becomes recovered, therefore the rule type reports 3 active (`alert-1`, `alert-3`, `alert-4`), 1 recovered (`alert-2`) alert. 3. Alerts `alert-1`, `alert-3`, `alert-4` are saved in the task state. 4. `alert-2` becomes active again (the others are still active) 5. Rule type reports 3 active alerts (`alert-1`, `alert-2`, `alert-3`) 6. As a result, the action scheduler tries to schedule actions for `alert-1`, `alert-3`, `alert-4` as they are the existing alerts. But, since the rule type didn't report the `alert-4` it has no scheduled actions, therefore the action scheduler assumes it is recovered and tries to schedule a recovery action. This PR changes the actionScheduler to handle active and recovered alerts separately. With this change, no action would be scheduled for an alert from previous run (exists in the task state) and isn't reported by the ruleTypeExecutor due to max-alerts-limit but it would be kept in the task state. --- .../alerting/server/alerts_client/types.ts | 7 +- .../action_scheduler/action_scheduler.test.ts | 298 +++++++++++++----- .../action_scheduler/action_scheduler.ts | 16 +- .../lib/get_summarized_alerts.ts | 2 +- .../per_alert_action_scheduler.test.ts | 99 ++++-- .../schedulers/per_alert_action_scheduler.ts | 198 +++++++----- .../summary_action_scheduler.test.ts | 62 +++- .../schedulers/summary_action_scheduler.ts | 4 +- .../system_action_scheduler.test.ts | 14 +- .../task_runner/action_scheduler/types.ts | 30 +- .../server/task_runner/task_runner.test.ts | 4 +- .../server/task_runner/task_runner.ts | 4 +- 12 files changed, 542 insertions(+), 196 deletions(-) diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts index d043f41e1e955..f3c4a85fa1b71 100644 --- a/x-pack/plugins/alerting/server/alerts_client/types.ts +++ b/x-pack/plugins/alerting/server/alerts_client/types.ts @@ -77,8 +77,11 @@ export interface IAlertsClient< processAlerts(opts: ProcessAlertsOpts): void; logAlerts(opts: LogAlertsOpts): void; getProcessedAlerts( - type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' - ): Record<string, LegacyAlert<State, Context, ActionGroupIds | RecoveryActionGroupId>>; + type: 'new' | 'active' | 'activeCurrent' + ): Record<string, LegacyAlert<State, Context, ActionGroupIds>> | {}; + getProcessedAlerts( + type: 'recovered' | 'recoveredCurrent' + ): Record<string, LegacyAlert<State, Context, RecoveryActionGroupId>> | {}; persistAlerts(): Promise<{ alertIds: string[]; maintenanceWindowIds: string[] } | null>; isTrackedAlert(id: string): boolean; getSummarizedAlerts?(params: GetSummarizedAlertsParams): Promise<SummarizedAlerts>; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts index b6f250b47205e..00f1a87aefd71 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts @@ -95,6 +95,7 @@ describe('Action Scheduler', () => { ); ruleRunMetricsStore = new RuleRunMetricsStore(); actionsClient.bulkEnqueueExecution.mockResolvedValue(defaultExecutionResponse); + alertsClient.getProcessedAlerts.mockReturnValue({}); }); beforeAll(() => { clock = sinon.useFakeTimers(); @@ -104,7 +105,7 @@ describe('Action Scheduler', () => { test('schedules execution per selected action', async () => { const alerts = generateAlert({ id: 1 }); const actionScheduler = new ActionScheduler(getSchedulerContext()); - await actionScheduler.run(alerts); + await actionScheduler.run({ activeCurrentAlerts: alerts, recoveredCurrentAlerts: {} }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); @@ -204,7 +205,10 @@ describe('Action Scheduler', () => { }) ); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); @@ -269,7 +273,10 @@ describe('Action Scheduler', () => { }) ); - await actionScheduler.run(generateAlert({ id: 2 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2 }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); @@ -281,7 +288,10 @@ describe('Action Scheduler', () => { ruleRunMetricsStore, }); - await actionSchedulerForPreconfiguredAction.run(generateAlert({ id: 2 })); + await actionSchedulerForPreconfiguredAction.run({ + activeCurrentAlerts: generateAlert({ id: 2 }), + recoveredCurrentAlerts: {}, + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); @@ -321,7 +331,10 @@ describe('Action Scheduler', () => { ); try { - await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, state: { value: 'state-val' } }), + recoveredCurrentAlerts: {}, + }); } catch (err) { expect(getErrorSource(err)).toBe(TaskErrorSource.USER); } @@ -329,7 +342,10 @@ describe('Action Scheduler', () => { test('limits actionsPlugin.execute per action group', async () => { const actionScheduler = new ActionScheduler(getSchedulerContext()); - await actionScheduler.run(generateAlert({ id: 2, group: 'other-group' })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, group: 'other-group' }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); @@ -337,7 +353,10 @@ describe('Action Scheduler', () => { test('context attribute gets parameterized', async () => { const actionScheduler = new ActionScheduler(getSchedulerContext()); - await actionScheduler.run(generateAlert({ id: 2, context: { value: 'context-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, context: { value: 'context-val' } }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); @@ -381,7 +400,10 @@ describe('Action Scheduler', () => { test('state attribute gets parameterized', async () => { const actionScheduler = new ActionScheduler(getSchedulerContext()); - await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, state: { value: 'state-val' } }), + recoveredCurrentAlerts: {}, + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -423,9 +445,13 @@ describe('Action Scheduler', () => { test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { const actionScheduler = new ActionScheduler(getSchedulerContext()); - await actionScheduler.run( - generateAlert({ id: 2, group: 'invalid-group' as 'default' | 'other-group' }) - ); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ + id: 2, + group: 'invalid-group' as 'default' | 'other-group', + }), + recoveredCurrentAlerts: {}, + }); expect(defaultSchedulerContext.logger.error).toHaveBeenCalledWith( 'Invalid action group "invalid-group" for rule "test".' @@ -503,7 +529,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, state: { value: 'state-val' } }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); @@ -604,7 +633,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, state: { value: 'state-val' } }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); @@ -688,7 +720,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2, state: { value: 'state-val' } }), + recoveredCurrentAlerts: {}, + }); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); @@ -722,7 +757,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: generateRecoveredAlert({ id: 1 }), + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -787,7 +825,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: generateRecoveredAlert({ id: 1 }), + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); expect(defaultSchedulerContext.logger.debug).nthCalledWith( @@ -807,7 +848,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); clock.tick(30000); @@ -837,12 +881,13 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run( - generateAlert({ + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1, throttledActions: { '111-111': { date: new Date(DATE_1970).toISOString() } }, - }) - ); + }), + recoveredCurrentAlerts: {}, + }); clock.tick(30000); @@ -872,7 +917,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 1, lastScheduledActionsGroup: 'recovered' })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1, lastScheduledActionsGroup: 'recovered' }), + recoveredCurrentAlerts: {}, + }); clock.tick(30000); @@ -890,7 +938,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); expect(defaultSchedulerContext.logger.debug).nthCalledWith( @@ -945,7 +996,10 @@ describe('Action Scheduler', () => { }) ); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -1026,7 +1080,10 @@ describe('Action Scheduler', () => { }) ); - await actionScheduler.run({}); + await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: {}, + }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); @@ -1078,7 +1135,10 @@ describe('Action Scheduler', () => { }) ); - const result = await actionScheduler.run({}); + const result = await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: {}, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ start: new Date('1969-12-31T00:01:30.000Z'), @@ -1174,7 +1234,10 @@ describe('Action Scheduler', () => { }) ); - await actionScheduler.run({}); + await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: {}, + }); expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( "skipping scheduling the action 'testActionTypeId:1', summary action is still being throttled" @@ -1236,7 +1299,10 @@ describe('Action Scheduler', () => { }) ); - const result = await actionScheduler.run({}); + const result = await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: {}, + }); expect(result).toEqual({ throttledSummaryActions: { '111-111': { @@ -1271,7 +1337,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateAlert({ id: 2 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 2 }), + recoveredCurrentAlerts: {}, + }); expect(defaultSchedulerContext.logger.error).toHaveBeenCalledWith( 'Skipping action "1" for rule "1" because the rule type "Test" does not support alert-as-data.' @@ -1332,7 +1401,10 @@ describe('Action Scheduler', () => { }, }) ); - await actionScheduler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: {}, + recoveredCurrentAlerts: generateRecoveredAlert({ id: 1 }), + }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -1455,8 +1527,11 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1 }), - ...generateAlert({ id: 2 }), + activeCurrentAlerts: { + ...generateAlert({ id: 1 }), + ...generateAlert({ id: 2 }), + }, + recoveredCurrentAlerts: {}, }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -1529,8 +1604,11 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1 }), - ...generateAlert({ id: 2 }), + activeCurrentAlerts: { + ...generateAlert({ id: 1 }), + ...generateAlert({ id: 2 }), + }, + recoveredCurrentAlerts: {}, }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -1597,9 +1675,12 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1 }), - ...generateAlert({ id: 2 }), - ...generateAlert({ id: 3 }), + activeCurrentAlerts: { + ...generateAlert({ id: 1 }), + ...generateAlert({ id: 2 }), + ...generateAlert({ id: 3 }), + }, + recoveredCurrentAlerts: {}, }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -1706,9 +1787,12 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), - ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), - ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + activeCurrentAlerts: { + ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), + ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), + ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + }, + recoveredCurrentAlerts: {}, }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); @@ -1755,9 +1839,12 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), - ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), - ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + activeCurrentAlerts: { + ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), + ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), + ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + }, + recoveredCurrentAlerts: {}, }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); @@ -1773,9 +1860,12 @@ describe('Action Scheduler', () => { const actionScheduler = new ActionScheduler(getSchedulerContext()); await actionScheduler.run({ - ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), - ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), - ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + activeCurrentAlerts: { + ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), + ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), + ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + }, + recoveredCurrentAlerts: {}, }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); @@ -1813,9 +1903,24 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), - ...generateAlert({ id: 2, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), - ...generateAlert({ id: 3, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), + activeCurrentAlerts: { + ...generateAlert({ + id: 1, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 2, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 3, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + }, + recoveredCurrentAlerts: {}, }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); @@ -1842,9 +1947,24 @@ describe('Action Scheduler', () => { ); await actionScheduler.run({ - ...generateAlert({ id: 1, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), - ...generateAlert({ id: 2, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), - ...generateAlert({ id: 3, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), + activeCurrentAlerts: { + ...generateAlert({ + id: 1, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 2, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + ...generateAlert({ + id: 3, + pendingRecoveredCount: 1, + lastScheduledActionsGroup: 'recovered', + }), + }, + recoveredCurrentAlerts: {}, }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); @@ -1991,7 +2111,11 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); + + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2024,7 +2148,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0][0].actionParams).toEqual({ val: 'rule url: http://localhost:12345/kbn/s/test1/app/management/insightsAndAlerting/triggersActions/rule/1', @@ -2064,8 +2191,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2100,8 +2229,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2133,8 +2264,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2166,8 +2299,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2196,8 +2331,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2226,8 +2363,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2259,8 +2398,10 @@ describe('Action Scheduler', () => { }; const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); - await actionScheduler.run(generateAlert({ id: 1 })); - + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -2328,8 +2469,10 @@ describe('Action Scheduler', () => { const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await actionScheduler.run(generateAlert({ id: 1 })); - + const res = await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); /** * Verifies that system actions are not throttled */ @@ -2451,7 +2594,10 @@ describe('Action Scheduler', () => { const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await actionScheduler.run(generateAlert({ id: 1 })); + const res = await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); /** * Verifies that system actions are not throttled @@ -2508,7 +2654,10 @@ describe('Action Scheduler', () => { const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await actionScheduler.run(generateAlert({ id: 1 })); + const res = await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(res).toEqual({ throttledSummaryActions: {} }); expect(buildActionParams).not.toHaveBeenCalled(); @@ -2547,7 +2696,10 @@ describe('Action Scheduler', () => { const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - await actionScheduler.run(generateAlert({ id: 1 })); + await actionScheduler.run({ + activeCurrentAlerts: generateAlert({ id: 1 }), + recoveredCurrentAlerts: {}, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(buildActionParams).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts index 44822657ba86f..fa16cfcabb094 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts @@ -74,9 +74,13 @@ export class ActionScheduler< this.schedulers.sort((a, b) => a.priority - b.priority); } - public async run( - alerts: Record<string, Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>> - ): Promise<RunResult> { + public async run({ + activeCurrentAlerts, + recoveredCurrentAlerts, + }: { + activeCurrentAlerts?: Record<string, Alert<State, Context, ActionGroupIds>>; + recoveredCurrentAlerts?: Record<string, Alert<State, Context, RecoveryActionGroupId>>; + }): Promise<RunResult> { const throttledSummaryActions: ThrottledActions = getSummaryActionsFromTaskState({ actions: this.context.rule.actions, summaryActions: this.context.taskInstance.state?.summaryActions, @@ -85,7 +89,11 @@ export class ActionScheduler< const allActionsToScheduleResult: ActionsToSchedule[] = []; for (const scheduler of this.schedulers) { allActionsToScheduleResult.push( - ...(await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions })) + ...(await scheduler.getActionsToSchedule({ + activeCurrentAlerts, + recoveredCurrentAlerts, + throttledSummaryActions, + })) ); } diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts index 00e155856d946..56d9c08c8b98f 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/lib/get_summarized_alerts.ts @@ -56,7 +56,7 @@ export const getSummarizedAlerts = async < * yet (the update call uses refresh: false). So we need to rely on the in * memory alerts to do this. */ - const newAlertsInMemory = Object.values(alertsClient.getProcessedAlerts('new') || {}) || []; + const newAlertsInMemory = Object.values(alertsClient.getProcessedAlerts('new')); const newAlertsWithMaintenanceWindowIds = newAlertsInMemory.reduce<string[]>((result, alert) => { if (alert.getMaintenanceWindowIds().length > 0) { diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts index 99a693133a2a6..62e501f6963af 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts @@ -213,7 +213,9 @@ describe('Per-Alert Action Scheduler', () => { test('should create action to schedule for each alert and each action', async () => { // 2 per-alert actions * 2 alerts = 4 actions to schedule const scheduler = new PerAlertActionScheduler(getSchedulerContext()); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -243,7 +245,9 @@ describe('Per-Alert Action Scheduler', () => { maintenanceWindowIds: ['mw-1'], }); const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; - const results = await scheduler.getActionsToSchedule({ alerts: alertsWithMaintenanceWindow }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alertsWithMaintenanceWindow, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(2); @@ -281,7 +285,7 @@ describe('Per-Alert Action Scheduler', () => { }); const alertsWithInvalidActionGroup = { ...newAlertInvalidActionGroup, ...newAlert2 }; const results = await scheduler.getActionsToSchedule({ - alerts: alertsWithInvalidActionGroup, + activeCurrentAlerts: alertsWithInvalidActionGroup, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -309,6 +313,35 @@ describe('Per-Alert Action Scheduler', () => { ]); }); + test('should skip creating actions to schedule when alert has no scheduled actions', async () => { + // 2 per-alert actions * 2 alerts = 4 actions to schedule + // but alert 1 has has no scheduled actions, so only actions for alert 2 should be scheduled + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + const newAlertInvalidActionGroup = generateAlert({ + id: 1, + scheduleActions: false, + }); + const alertsWithInvalidActionGroup = { ...newAlertInvalidActionGroup, ...newAlert2 }; + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alertsWithInvalidActionGroup, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(2); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toEqual(2); + expect(ruleRunMetricsStore.getStatusByConnectorType('test')).toEqual({ + numberOfGeneratedActions: 2, + numberOfTriggeredActions: 2, + }); + + expect(results).toHaveLength(2); + expect(results).toEqual([ + getResult('action-1', '2', '111-111'), + getResult('action-2', '2', '222-222'), + ]); + }); + test('should skip creating actions to schedule when alert has pending recovered count greater than 0 and notifyWhen is onActiveAlert', async () => { // 2 per-alert actions * 2 alerts = 4 actions to schedule // but alert 1 has a pending recovered count > 0 & notifyWhen is onActiveAlert, so only actions for alert 2 should be scheduled @@ -322,7 +355,7 @@ describe('Per-Alert Action Scheduler', () => { ...newAlert2, }; const results = await scheduler.getActionsToSchedule({ - alerts: alertsWithPendingRecoveredCount, + activeCurrentAlerts: alertsWithPendingRecoveredCount, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -368,7 +401,7 @@ describe('Per-Alert Action Scheduler', () => { ...newAlert2, }; const results = await scheduler.getActionsToSchedule({ - alerts: alertsWithPendingRecoveredCount, + activeCurrentAlerts: alertsWithPendingRecoveredCount, }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -394,7 +427,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, mutedInstanceIds: ['2'] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -453,7 +488,9 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onActionGroupChangeAction] }, }); - const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alertsWithOngoingAlert, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -508,7 +545,9 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, }); - const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alertsWithOngoingAlert, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -563,7 +602,9 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, }); - const results = await scheduler.getActionsToSchedule({ alerts: alertsWithOngoingAlert }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alertsWithOngoingAlert, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -620,7 +661,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -679,7 +722,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -739,7 +784,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -799,7 +846,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -860,7 +909,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -919,7 +970,9 @@ describe('Per-Alert Action Scheduler', () => { ...getSchedulerContext(), rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -960,7 +1013,9 @@ describe('Per-Alert Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -996,7 +1051,9 @@ describe('Per-Alert Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -1029,7 +1086,9 @@ describe('Per-Alert Action Scheduler', () => { expect(alert.getLastScheduledActions()).toBeUndefined(); expect(alert.hasScheduledActions()).toBe(true); - await scheduler.getActionsToSchedule({ alerts: { '1': alert } }); + await scheduler.getActionsToSchedule({ + activeCurrentAlerts: { '1': alert }, + }); expect(alert.getLastScheduledActions()).toEqual({ date: '1970-01-01T00:00:00.000Z', @@ -1066,7 +1125,9 @@ describe('Per-Alert Action Scheduler', () => { rule: { ...rule, actions: [onThrottleIntervalAction] }, }); - await scheduler.getActionsToSchedule({ alerts: { '1': alert } }); + await scheduler.getActionsToSchedule({ + activeCurrentAlerts: { '1': alert }, + }); expect(alert.getLastScheduledActions()).toEqual({ date: '1970-01-01T00:00:00.000Z', diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts index b35d86dff0105..28b35d885b3d2 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts @@ -25,8 +25,12 @@ import { import { ActionSchedulerOptions, ActionsToSchedule, + AddSummarizedAlertsOpts, GetActionsToScheduleOpts, + HelperOpts, IActionScheduler, + IsExecutableActiveAlertOpts, + IsExecutableAlertOpts, } from '../types'; import { TransformActionParamsOptions, transformActionParams } from '../../transform_action_params'; import { injectActionParams } from '../../inject_action_params'; @@ -96,7 +100,8 @@ export class PerAlertActionScheduler< } public async getActionsToSchedule({ - alerts, + activeCurrentAlerts, + recoveredCurrentAlerts, }: GetActionsToScheduleOpts<State, Context, ActionGroupIds, RecoveryActionGroupId>): Promise< ActionsToSchedule[] > { @@ -106,7 +111,9 @@ export class PerAlertActionScheduler< }> = []; const results: ActionsToSchedule[] = []; - const alertsArray = Object.entries(alerts); + const activeCurrentAlertsArray = Object.values(activeCurrentAlerts || {}); + const recoveredCurrentAlertsArray = Object.values(recoveredCurrentAlerts || {}); + for (const action of this.actions) { let summarizedAlerts = null; @@ -133,61 +140,26 @@ export class PerAlertActionScheduler< logNumberOfFilteredAlerts({ logger: this.context.logger, - numberOfAlerts: Object.entries(alerts).length, + numberOfAlerts: activeCurrentAlertsArray.length + recoveredCurrentAlertsArray.length, numberOfSummarizedAlerts: summarizedAlerts.all.count, action, }); } - for (const [alertId, alert] of alertsArray) { - const alertMaintenanceWindowIds = alert.getMaintenanceWindowIds(); - if (alertMaintenanceWindowIds.length !== 0) { - this.context.logger.debug( - `no scheduling of summary actions "${action.id}" for rule "${ - this.context.rule.id - }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` - ); - continue; - } - - if (alert.isFilteredOut(summarizedAlerts)) { - continue; - } - - const actionGroup = - alert.getScheduledActionOptions()?.actionGroup || - this.context.ruleType.recoveryActionGroup.id; - - if (!this.ruleTypeActionGroups!.has(actionGroup)) { - this.context.logger.error( - `Invalid action group "${actionGroup}" for rule "${this.context.ruleType.id}".` - ); - continue; - } - - // only actions with notifyWhen set to "on status change" should return - // notifications for flapping pending recovered alerts + for (const alert of activeCurrentAlertsArray) { if ( - alert.getPendingRecoveredCount() > 0 && - action?.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE + this.isExecutableAlert({ alert, action, summarizedAlerts }) && + this.isExecutableActiveAlert({ alert, action }) ) { - continue; - } - - if (summarizedAlerts) { - const alertAsData = summarizedAlerts.all.data.find( - (alertHit: AlertHit) => alertHit._id === alert.getUuid() - ); - if (alertAsData) { - alert.setAlertAsData(alertAsData); - } + this.addSummarizedAlerts({ alert, summarizedAlerts }); + executables.push({ action, alert }); } + } - if (action.group === actionGroup && !this.isAlertMuted(alertId)) { - if ( - this.isRecoveredAlert(action.group) || - this.isExecutableActiveAlert({ alert, action }) - ) { + if (this.isRecoveredAction(action.group)) { + for (const alert of recoveredCurrentAlertsArray) { + if (this.isExecutableAlert({ alert, action, summarizedAlerts })) { + this.addSummarizedAlerts({ alert, summarizedAlerts }); executables.push({ action, alert }); } } @@ -285,7 +257,7 @@ export class PerAlertActionScheduler< }, }); - if (!this.isRecoveredAlert(actionGroup)) { + if (!this.isRecoveredAction(actionGroup)) { if (isActionOnInterval(action)) { alert.updateLastScheduledActions( action.group as ActionGroupIds, @@ -302,30 +274,34 @@ export class PerAlertActionScheduler< return results; } - private isAlertMuted(alertId: string) { - const muted = this.mutedAlertIdsSet.has(alertId); - if (muted) { - if ( - !this.skippedAlerts[alertId] || - (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) - ) { - this.context.logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${this.context.ruleLabel}: rule is muted` - ); - } - this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; - return true; - } - return false; - } - - private isExecutableActiveAlert({ + private isExecutableAlert({ alert, action, - }: { - alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds | RecoveryActionGroupId>; - action: RuleAction; - }) { + summarizedAlerts, + }: IsExecutableAlertOpts<ActionGroupIds, RecoveryActionGroupId>) { + return ( + !this.hasActiveMaintenanceWindow({ alert, action }) && + !this.isAlertMuted(alert) && + !this.hasPendingCountButNotNotifyOnChange({ alert, action }) && + !alert.isFilteredOut(summarizedAlerts) + ); + } + + private isExecutableActiveAlert({ alert, action }: IsExecutableActiveAlertOpts<ActionGroupIds>) { + if (!alert.hasScheduledActions()) { + return false; + } + + const alertsActionGroup = alert.getScheduledActionOptions()?.actionGroup; + + if (!this.isValidActionGroup(alertsActionGroup as ActionGroupIds)) { + return false; + } + + if (action.group !== alertsActionGroup) { + return false; + } + const alertId = alert.getId(); const { context: { rule, logger, ruleLabel }, @@ -369,10 +345,86 @@ export class PerAlertActionScheduler< } } - return alert.hasScheduledActions(); + return true; } - private isRecoveredAlert(actionGroup: string) { + private isRecoveredAction(actionGroup: string) { return actionGroup === this.context.ruleType.recoveryActionGroup.id; } + + private isAlertMuted( + alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds | RecoveryActionGroupId> + ) { + const alertId = alert.getId(); + const muted = this.mutedAlertIdsSet.has(alertId); + if (muted) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) + ) { + this.context.logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${this.context.ruleLabel}: rule is muted` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; + return true; + } + return false; + } + + private isValidActionGroup(actionGroup: ActionGroupIds | RecoveryActionGroupId) { + if (!this.ruleTypeActionGroups!.has(actionGroup)) { + this.context.logger.error( + `Invalid action group "${actionGroup}" for rule "${this.context.ruleType.id}".` + ); + return false; + } + return true; + } + + private hasActiveMaintenanceWindow({ + alert, + action, + }: HelperOpts<ActionGroupIds, RecoveryActionGroupId>) { + const alertMaintenanceWindowIds = alert.getMaintenanceWindowIds(); + if (alertMaintenanceWindowIds.length !== 0) { + this.context.logger.debug( + `no scheduling of summary actions "${action.id}" for rule "${ + this.context.rule.id + }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` + ); + return true; + } + + return false; + } + + private addSummarizedAlerts({ + alert, + summarizedAlerts, + }: AddSummarizedAlertsOpts<ActionGroupIds, RecoveryActionGroupId>) { + if (summarizedAlerts) { + const alertAsData = summarizedAlerts.all.data.find( + (alertHit: AlertHit) => alertHit._id === alert.getUuid() + ); + if (alertAsData) { + alert.setAlertAsData(alertAsData); + } + } + } + + private hasPendingCountButNotNotifyOnChange({ + alert, + action, + }: HelperOpts<ActionGroupIds, RecoveryActionGroupId>) { + // only actions with notifyWhen set to "on status change" should return + // notifications for flapping pending recovered alerts + if ( + alert.getPendingRecoveredCount() > 0 && + action?.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE + ) { + return true; + } + return false; + } } diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts index fc810fc4ef34c..cb19cb781ae3e 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts @@ -13,7 +13,13 @@ import { alertingEventLoggerMock } from '../../../lib/alerting_event_logger/aler import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; import { mockAAD } from '../../fixtures'; import { SummaryActionScheduler } from './summary_action_scheduler'; -import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; +import { + getRule, + getRuleType, + getDefaultSchedulerContext, + generateAlert, + generateRecoveredAlert, +} from '../test_fixtures'; import { RuleAction } from '@kbn/alerting-types'; import { ALERT_UUID } from '@kbn/rule-data-utils'; import { @@ -165,6 +171,7 @@ describe('Summary Action Scheduler', () => { describe('getActionsToSchedule', () => { const newAlert1 = generateAlert({ id: 1 }); const newAlert2 = generateAlert({ id: 2 }); + const recoveredAlert = generateRecoveredAlert({ id: 3 }); const alerts = { ...newAlert1, ...newAlert2 }; const summaryActionWithAlertFilter: RuleAction = { @@ -217,7 +224,10 @@ describe('Summary Action Scheduler', () => { const throttledSummaryActions = {}; const scheduler = new SummaryActionScheduler(getSchedulerContext()); - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); @@ -266,7 +276,10 @@ describe('Summary Action Scheduler', () => { }); const throttledSummaryActions = {}; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); @@ -307,7 +320,10 @@ describe('Summary Action Scheduler', () => { }); const throttledSummaryActions = {}; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({ '444-444': { date: '1970-01-01T00:00:00.000Z' } }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); @@ -340,7 +356,10 @@ describe('Summary Action Scheduler', () => { }); const throttledSummaryActions = { '444-444': { date: '1969-12-31T13:00:00.000Z' } }; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({ '444-444': { date: '1969-12-31T13:00:00.000Z' } }); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -374,7 +393,10 @@ describe('Summary Action Scheduler', () => { const scheduler = new SummaryActionScheduler(getSchedulerContext()); const throttledSummaryActions = {}; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); @@ -436,7 +458,11 @@ describe('Summary Action Scheduler', () => { }); const throttledSummaryActions = {}; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + recoveredCurrentAlerts: recoveredAlert, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); @@ -449,7 +475,7 @@ describe('Summary Action Scheduler', () => { }); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith( - `(1) alert has been filtered out for: test:333-333` + `(2) alerts have been filtered out for: test:333-333` ); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toEqual(1); @@ -480,7 +506,10 @@ describe('Summary Action Scheduler', () => { }); const throttledSummaryActions = {}; - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions, + }); expect(throttledSummaryActions).toEqual({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); @@ -507,7 +536,10 @@ describe('Summary Action Scheduler', () => { const scheduler = new SummaryActionScheduler(getSchedulerContext()); try { - await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); + await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions: {}, + }); } catch (err) { expect(err.message).toEqual(`no alerts for you`); expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); @@ -533,7 +565,10 @@ describe('Summary Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions: {}, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { @@ -587,7 +622,10 @@ describe('Summary Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts, throttledSummaryActions: {} }); + const results = await scheduler.getActionsToSchedule({ + activeCurrentAlerts: alerts, + throttledSummaryActions: {}, + }); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts index 050eea352f0d5..db53f15be2180 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts @@ -81,11 +81,13 @@ export class SummaryActionScheduler< } public async getActionsToSchedule({ - alerts, + activeCurrentAlerts, + recoveredCurrentAlerts, throttledSummaryActions, }: GetActionsToScheduleOpts<State, Context, ActionGroupIds, RecoveryActionGroupId>): Promise< ActionsToSchedule[] > { + const alerts = { ...activeCurrentAlerts, ...recoveredCurrentAlerts }; const executables: Array<{ action: RuleAction; summarizedAlerts: CombinedSummarizedAlerts; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts index 28bf58a30c689..71a7584c7280b 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts @@ -160,7 +160,7 @@ describe('System Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SystemActionScheduler(getSchedulerContext()); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -202,7 +202,7 @@ describe('System Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SystemActionScheduler(getSchedulerContext()); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -240,7 +240,7 @@ describe('System Action Scheduler', () => { alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); const scheduler = new SystemActionScheduler(getSchedulerContext()); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ @@ -265,7 +265,7 @@ describe('System Action Scheduler', () => { const scheduler = new SystemActionScheduler(getSchedulerContext()); try { - await scheduler.getActionsToSchedule({ alerts }); + await scheduler.getActionsToSchedule({}); } catch (err) { expect(err.message).toEqual(`no alerts for you`); expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); @@ -299,7 +299,7 @@ describe('System Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { @@ -361,7 +361,7 @@ describe('System Action Scheduler', () => { }, }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { @@ -416,7 +416,7 @@ describe('System Action Scheduler', () => { ...defaultContext, rule: { ...rule, systemActions: [differentSystemAction] }, }); - const results = await scheduler.getActionsToSchedule({ alerts }); + const results = await scheduler.getActionsToSchedule({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts index b90ffb88d541b..02b9647f91866 100644 --- a/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts @@ -90,7 +90,8 @@ export interface GetActionsToScheduleOpts< ActionGroupIds extends string, RecoveryActionGroupId extends string > { - alerts: Record<string, Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>>; + activeCurrentAlerts?: Record<string, Alert<State, Context, ActionGroupIds>>; + recoveredCurrentAlerts?: Record<string, Alert<State, Context, RecoveryActionGroupId>>; throttledSummaryActions?: ThrottledActions; } @@ -118,3 +119,30 @@ export interface RuleUrl { spaceIdSegment?: string; relativePath?: string; } + +export interface IsExecutableAlertOpts< + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds | RecoveryActionGroupId>; + action: RuleAction; + summarizedAlerts: CombinedSummarizedAlerts | null; +} + +export interface IsExecutableActiveAlertOpts<ActionGroupIds extends string> { + alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds>; + action: RuleAction; +} + +export interface HelperOpts<ActionGroupIds extends string, RecoveryActionGroupId extends string> { + alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds | RecoveryActionGroupId>; + action: RuleAction; +} + +export interface AddSummarizedAlertsOpts< + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert<AlertInstanceState, AlertInstanceContext, ActionGroupIds | RecoveryActionGroupId>; + summarizedAlerts: CombinedSummarizedAlerts | null; +} diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index b6e59402ba4c6..a79dfe8f59c73 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -1677,6 +1677,7 @@ describe('Task Runner', () => { return { state: {} }; }); + alertsClient.getProcessedAlerts.mockReturnValue({}); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1738,7 +1739,7 @@ describe('Task Runner', () => { ruleType.executor.mockImplementation(async () => { return { state: {} }; }); - + alertsClient.getProcessedAlerts.mockReturnValue({}); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1747,6 +1748,7 @@ describe('Task Runner', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); + alertsClient.getAlertsToSerialize.mockResolvedValueOnce({ state: {}, meta: {} }); alertsService.createAlertsClient.mockImplementation(() => alertsClient); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 897937ce55a0a..89432e1822029 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -414,8 +414,8 @@ export class TaskRunner< this.countUsageOfActionExecutionAfterRuleCancellation(); } else { actionSchedulerResult = await actionScheduler.run({ - ...alertsClient.getProcessedAlerts('activeCurrent'), - ...alertsClient.getProcessedAlerts('recoveredCurrent'), + activeCurrentAlerts: alertsClient.getProcessedAlerts('activeCurrent'), + recoveredCurrentAlerts: alertsClient.getProcessedAlerts('recoveredCurrent'), }); } }) From 8cadf88c66a257c073279fa11572b089c32eb643 Mon Sep 17 00:00:00 2001 From: Jen Huang <its.jenetic@gmail.com> Date: Tue, 15 Oct 2024 16:57:32 -0700 Subject: [PATCH 24/31] [UII] Restrict agentless integrations to deployments with agentless enabled (#194885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves #192486. This PR makes it so that on deployments without agentless enabled: 1. Agentless-only integrations are hidden from the browse integration UI 2. Agentless-only integrations cannot be installed via API (unless force flag is used) ⚠️ https://github.com/elastic/package-registry/issues/1238 needs to be completed for the below testing steps to work. Currently EPR does not return `deployment_modes` property which is necessary for Fleet to know which packages are agentless. ## How to test 1. Simulate agentless being available by adding the following to kibana.yml: ``` xpack.fleet.agentless.enabled: true # Simulate cloud xpack.cloud.id: "foo" xpack.cloud.base_url: "https://cloud.elastic.co" xpack.cloud.organization_url: "/account/" xpack.cloud.billing_url: "/billing/" xpack.cloud.profile_url: "/user/settings/" ``` 2. Go to `Integrations > Browse` and enable showing Beta integrations, search for `connector` and you should see the agentless integrations: Elastic Connectors, GitHub & GitHub Enterprise Server Connector, Google Drive Connector 3. Install any one of them (they all come from the same package), it should be successful 4. Uninstall them 5. Remove config changes to go back to a non-agentless deployment 6. Refresh Integrations list, the three integrations should no longer appear 7. Try installing via API, an error should appear ``` POST kbn:/api/fleet/epm/packages/elastic_connectors/0.0.2 ``` 8. Try installing via API again with force flag, it should be successful: ``` POST kbn:/api/fleet/epm/packages/elastic_connectors/0.0.2 { "force": true } ``` ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../services/agentless_policy_helper.test.ts | 287 ++++++++++++++++++ .../services/agentless_policy_helper.ts | 41 +++ .../hooks/setup_technology.ts | 14 +- .../hooks/use_package_policy_steps.tsx | 1 - .../home/hooks/use_available_packages.tsx | 38 ++- .../plugins/fleet/public/hooks/use_config.ts | 23 +- x-pack/plugins/fleet/public/hooks/use_core.ts | 7 +- .../fleet/public/mock/plugin_interfaces.ts | 2 + x-pack/plugins/fleet/public/plugin.ts | 3 +- .../plugins/fleet/server/errors/handlers.ts | 4 + x-pack/plugins/fleet/server/errors/index.ts | 1 + .../services/epm/packages/install.test.ts | 69 ++++- .../server/services/epm/packages/install.ts | 18 ++ .../fleet/server/services/utils/agentless.ts | 7 +- 14 files changed, 488 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/fleet/common/services/agentless_policy_helper.test.ts diff --git a/x-pack/plugins/fleet/common/services/agentless_policy_helper.test.ts b/x-pack/plugins/fleet/common/services/agentless_policy_helper.test.ts new file mode 100644 index 0000000000000..aed3020c9dcf1 --- /dev/null +++ b/x-pack/plugins/fleet/common/services/agentless_policy_helper.test.ts @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RegistryPolicyTemplate } from '../types'; + +import { + isAgentlessIntegration, + getAgentlessAgentPolicyNameFromPackagePolicyName, + isOnlyAgentlessIntegration, + isOnlyAgentlessPolicyTemplate, +} from './agentless_policy_helper'; + +describe('agentless_policy_helper', () => { + describe('isAgentlessIntegration', () => { + it('should return true if packageInfo is defined and has at least one agentless integration', () => { + const packageInfo = { + policy_templates: [ + { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + agentless: { + enabled: true, + }, + }, + }, + { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + }, + }, + ] as RegistryPolicyTemplate[], + }; + + const result = isAgentlessIntegration(packageInfo); + + expect(result).toBe(true); + }); + + it('should return false if packageInfo is defined but does not have agentless integrations', () => { + const packageInfo = { + policy_templates: [ + { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + agentless: { + enabled: false, + }, + }, + }, + { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + default: { + enabled: false, + }, + agentless: { + enabled: false, + }, + }, + }, + ] as RegistryPolicyTemplate[], + }; + + const result = isAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + + it('should return false if packageInfo has no policy templates', () => { + const packageInfo = { + policy_templates: [], + }; + + const result = isAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + + it('should return false if packageInfo is undefined', () => { + const packageInfo = undefined; + + const result = isAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + }); + + describe('getAgentlessAgentPolicyNameFromPackagePolicyName', () => { + it('should return the agentless agent policy name based on the package policy name', () => { + const packagePolicyName = 'example-package-policy'; + + const result = getAgentlessAgentPolicyNameFromPackagePolicyName(packagePolicyName); + + expect(result).toBe('Agentless policy for example-package-policy'); + }); + }); + + describe('isOnlyAgentlessIntegration', () => { + it('should return true if packageInfo is defined and has only agentless integration', () => { + const packageInfo = { + policy_templates: [ + { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: false, + }, + agentless: { + enabled: true, + }, + }, + }, + { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + agentless: { + enabled: true, + }, + }, + }, + ] as RegistryPolicyTemplate[], + }; + + const result = isOnlyAgentlessIntegration(packageInfo); + + expect(result).toBe(true); + }); + + it('should return false if packageInfo is defined but has other deployment types', () => { + const packageInfo = { + policy_templates: [ + { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + agentless: { + enabled: true, + }, + }, + }, + { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + }, + }, + ] as RegistryPolicyTemplate[], + }; + + const result = isOnlyAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + + it('should return false if packageInfo has no policy templates', () => { + const packageInfo = { + policy_templates: [], + }; + + const result = isOnlyAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + + it('should return false if packageInfo is undefined', () => { + const packageInfo = undefined; + + const result = isOnlyAgentlessIntegration(packageInfo); + + expect(result).toBe(false); + }); + }); + + describe('isOnlyAgentlessPolicyTemplate', () => { + it('should return true if the policy template is only agentless', () => { + const policyTemplate = { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: false, + }, + agentless: { + enabled: true, + }, + }, + }; + const policyTemplate2 = { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + agentless: { + enabled: true, + }, + }, + }; + + const result = isOnlyAgentlessPolicyTemplate(policyTemplate); + const result2 = isOnlyAgentlessPolicyTemplate(policyTemplate2); + + expect(result).toBe(true); + expect(result2).toBe(true); + }); + + it('should return false if the policy template has other deployment types', () => { + const policyTemplate = { + name: 'template1', + title: 'Template 1', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + agentless: { + enabled: true, + }, + }, + }; + const policyTemplate2 = { + name: 'template2', + title: 'Template 2', + description: '', + deployment_modes: { + default: { + enabled: true, + }, + agentless: { + enabled: false, + }, + }, + }; + + const result = isOnlyAgentlessPolicyTemplate(policyTemplate); + const result2 = isOnlyAgentlessPolicyTemplate(policyTemplate2); + + expect(result).toBe(false); + expect(result2).toBe(false); + }); + + it('should return false if the policy template has no deployment modes', () => { + const policyTemplate = { + name: 'template1', + title: 'Template 1', + description: '', + }; + + const result = isOnlyAgentlessPolicyTemplate(policyTemplate); + + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/fleet/common/services/agentless_policy_helper.ts b/x-pack/plugins/fleet/common/services/agentless_policy_helper.ts index ede0dfa497187..7093875ae84f5 100644 --- a/x-pack/plugins/fleet/common/services/agentless_policy_helper.ts +++ b/x-pack/plugins/fleet/common/services/agentless_policy_helper.ts @@ -5,6 +5,47 @@ * 2.0. */ +import type { PackageInfo, RegistryPolicyTemplate } from '../types'; + +export const isAgentlessIntegration = ( + packageInfo: Pick<PackageInfo, 'policy_templates'> | undefined +) => { + if ( + packageInfo?.policy_templates && + packageInfo?.policy_templates.length > 0 && + !!packageInfo?.policy_templates.find( + (policyTemplate) => policyTemplate?.deployment_modes?.agentless.enabled === true + ) + ) { + return true; + } + return false; +}; + export const getAgentlessAgentPolicyNameFromPackagePolicyName = (packagePolicyName: string) => { return `Agentless policy for ${packagePolicyName}`; }; + +export const isOnlyAgentlessIntegration = ( + packageInfo: Pick<PackageInfo, 'policy_templates'> | undefined +) => { + if ( + packageInfo?.policy_templates && + packageInfo?.policy_templates.length > 0 && + packageInfo?.policy_templates.every((policyTemplate) => + isOnlyAgentlessPolicyTemplate(policyTemplate) + ) + ) { + return true; + } + return false; +}; + +export const isOnlyAgentlessPolicyTemplate = (policyTemplate: RegistryPolicyTemplate) => { + return Boolean( + policyTemplate.deployment_modes && + policyTemplate.deployment_modes.agentless.enabled === true && + (!policyTemplate.deployment_modes.default || + policyTemplate.deployment_modes.default.enabled === false) + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts index 95b4aa80a02bb..241dcfbb93f4e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts @@ -20,7 +20,10 @@ import { SetupTechnology } from '../../../../../types'; import { sendGetOneAgentPolicy, useStartServices } from '../../../../../hooks'; import { SelectedPolicyTab } from '../../components'; import { AGENTLESS_POLICY_ID } from '../../../../../../../../common/constants'; -import { getAgentlessAgentPolicyNameFromPackagePolicyName } from '../../../../../../../../common/services/agentless_policy_helper'; +import { + isAgentlessIntegration as isAgentlessIntegrationFn, + getAgentlessAgentPolicyNameFromPackagePolicyName, +} from '../../../../../../../../common/services/agentless_policy_helper'; export const useAgentless = () => { const config = useConfig(); @@ -45,14 +48,7 @@ export const useAgentless = () => { // When an integration has at least a policy template enabled for agentless const isAgentlessIntegration = (packageInfo: PackageInfo | undefined) => { - if ( - isAgentlessEnabled && - packageInfo?.policy_templates && - packageInfo?.policy_templates.length > 0 && - !!packageInfo?.policy_templates.find( - (policyTemplate) => policyTemplate?.deployment_modes?.agentless.enabled === true - ) - ) { + if (isAgentlessEnabled && isAgentlessIntegrationFn(packageInfo)) { return true; } return false; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/hooks/use_package_policy_steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/hooks/use_package_policy_steps.tsx index dc055cec7fceb..1f2bdecf9e5ad 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/hooks/use_package_policy_steps.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/hooks/use_package_policy_steps.tsx @@ -135,7 +135,6 @@ export function usePackagePolicySteps({ setNewAgentPolicy, updateAgentPolicies, setSelectedPolicyTab, - packageInfo, packagePolicy, isEditPage: true, agentPolicies, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx index c7b1f936e2424..2f506b30b2626 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx @@ -11,8 +11,10 @@ import { uniq } from 'lodash'; import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common'; import type { IntegrationPreferenceType } from '../../../components/integration_preference'; -import { useGetPackagesQuery, useGetCategoriesQuery } from '../../../../../hooks'; +import { useAgentless } from '../../../../../../fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology'; import { + useGetPackagesQuery, + useGetCategoriesQuery, useGetAppendCustomIntegrationsQuery, useGetReplacementCustomIntegrationsQuery, } from '../../../../../hooks'; @@ -28,6 +30,11 @@ import { isIntegrationPolicyTemplate, } from '../../../../../../../../common/services'; +import { + isOnlyAgentlessPolicyTemplate, + isOnlyAgentlessIntegration, +} from '../../../../../../../../common/services/agentless_policy_helper'; + import type { IntegrationCardItem } from '..'; import { ALL_CATEGORY } from '../category_facets'; @@ -103,6 +110,23 @@ const packageListToIntegrationsList = (packages: PackageList): PackageList => { }, []); }; +// Return filtered packages based on deployment mode, +// Currently filters out agentless only packages and policy templates if agentless is not available +const filterPackageListDeploymentModes = (packages: PackageList, isAgentlessEnabled: boolean) => { + return isAgentlessEnabled + ? packages + : packages + .filter((pkg) => { + return !isOnlyAgentlessIntegration(pkg); + }) + .map((pkg) => { + pkg.policy_templates = (pkg.policy_templates || []).filter((policyTemplate) => { + return !isOnlyAgentlessPolicyTemplate(policyTemplate); + }); + return pkg; + }); +}; + export type AvailablePackagesHookType = typeof useAvailablePackages; export const useAvailablePackages = ({ @@ -113,6 +137,7 @@ export const useAvailablePackages = ({ const [preference, setPreference] = useState<IntegrationPreferenceType>('recommended'); const { showIntegrationsSubcategories } = ExperimentalFeaturesService.get(); + const { isAgentlessEnabled } = useAgentless(); const { initialSelectedCategory, @@ -146,10 +171,13 @@ export const useAvailablePackages = ({ }); } - const eprIntegrationList = useMemo( - () => packageListToIntegrationsList(eprPackages?.items || []), - [eprPackages] - ); + const eprIntegrationList = useMemo(() => { + const filteredPackageList = + filterPackageListDeploymentModes(eprPackages?.items || [], isAgentlessEnabled) || []; + const integrations = packageListToIntegrationsList(filteredPackageList); + return integrations; + }, [eprPackages?.items, isAgentlessEnabled]); + const { data: replacementCustomIntegrations, isInitialLoading: isLoadingReplacmentCustomIntegrations, diff --git a/x-pack/plugins/fleet/public/hooks/use_config.ts b/x-pack/plugins/fleet/public/hooks/use_config.ts index db86ed66bba60..2df3ed5f38a54 100644 --- a/x-pack/plugins/fleet/public/hooks/use_config.ts +++ b/x-pack/plugins/fleet/public/hooks/use_config.ts @@ -9,12 +9,27 @@ import React, { useContext } from 'react'; import type { FleetConfigType } from '../plugin'; +import { useStartServices } from '.'; + export const ConfigContext = React.createContext<FleetConfigType | null>(null); -export function useConfig() { - const config = useContext(ConfigContext); - if (config === null) { - throw new Error('ConfigContext not initialized'); +export function useConfig(): FleetConfigType { + const { fleet } = useStartServices(); + const baseConfig = useContext(ConfigContext); + + // Downstream plugins may set `fleet` as part of the Kibana context + // which means that the Fleet config is exposed in that way + const pluginConfig = fleet?.config; + const config = baseConfig || pluginConfig || null; + + if (baseConfig === null && pluginConfig) { + // eslint-disable-next-line no-console + console.warn('Fleet ConfigContext not initialized, using from plugin context'); } + + if (!config) { + throw new Error('Fleet ConfigContext not initialized'); + } + return config; } diff --git a/x-pack/plugins/fleet/public/hooks/use_core.ts b/x-pack/plugins/fleet/public/hooks/use_core.ts index 0e65686ac38a7..314e7931eb363 100644 --- a/x-pack/plugins/fleet/public/hooks/use_core.ts +++ b/x-pack/plugins/fleet/public/hooks/use_core.ts @@ -7,10 +7,11 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { FleetStartServices } from '../plugin'; +import type { FleetStart, FleetStartServices } from '../plugin'; -export function useStartServices(): FleetStartServices { - const { services } = useKibana<FleetStartServices>(); +// Downstream plugins may set `fleet` as part of the Kibana context +export function useStartServices(): FleetStartServices & { fleet?: FleetStart } { + const { services } = useKibana<FleetStartServices & { fleet?: FleetStart }>(); if (services === null) { throw new Error('KibanaContextProvider not initialized'); } diff --git a/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts b/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts index e2490eecfd766..5af34f2b0bc04 100644 --- a/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts +++ b/x-pack/plugins/fleet/public/mock/plugin_interfaces.ts @@ -9,6 +9,7 @@ import type { UIExtensionsStorage } from '../types'; import { createExtensionRegistrationCallback } from '../services/ui_extensions'; import type { MockedFleetStart } from './types'; +import { createConfigurationMock } from './plugin_configuration'; export const createStartMock = (extensionsStorage: UIExtensionsStorage = {}): MockedFleetStart => { return { @@ -41,6 +42,7 @@ export const createStartMock = (extensionsStorage: UIExtensionsStorage = {}): Mo writeIntegrationPolicies: true, }, }, + config: createConfigurationMock(), hooks: { epm: { getBulkAssets: jest.fn() } }, }; }; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index ce922f838ae4e..ced047f7cc0c4 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -102,6 +102,7 @@ export interface FleetSetup {} export interface FleetStart { /** Authorization for the current user */ authz: FleetAuthz; + config: FleetConfigType; registerExtension: UIExtensionRegistrationCallback; isInitialized: () => Promise<true>; hooks: { @@ -356,7 +357,7 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep // capabilities.fleetv2 returns fleet privileges and capabilities.fleet returns integrations privileges return { authz, - + config: this.config, isInitialized: once(async () => { const permissionsResponse = await getPermissions(); diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index 31e4b9d6704c7..2bdd118e7fb40 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -45,6 +45,7 @@ import { PackageSavedObjectConflictError, FleetTooManyRequestsError, AgentlessPolicyExistsRequestError, + PackageInvalidDeploymentMode, PackagePolicyContentPackageError, } from '.'; @@ -61,6 +62,9 @@ interface IngestErrorHandlerParams { // this type is based on BadRequest values observed while debugging https://github.com/elastic/kibana/issues/75862 const getHTTPResponseCode = (error: FleetError): number => { // Bad Request + if (error instanceof PackageInvalidDeploymentMode) { + return 400; + } if (error instanceof PackageFailedVerificationError) { return 400; } diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index de528f082c096..abc36f7df9692 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -29,6 +29,7 @@ export class RegistryResponseError extends RegistryError { // Package errors +export class PackageInvalidDeploymentMode extends FleetError {} export class PackageOutdatedError extends FleetError {} export class PackageFailedVerificationError extends FleetError { constructor(pkgName: string, pkgVersion: string) { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index a0bd8c8d77fe6..709e0d84d70fc 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -17,6 +17,7 @@ import { licenseService } from '../../license'; import { auditLoggingService } from '../../audit_logging'; import { appContextService } from '../../app_context'; import { ConcurrentInstallOperationError, FleetError, PackageNotFoundError } from '../../../errors'; +import { isAgentlessEnabled, isOnlyAgentlessIntegration } from '../../utils/agentless'; import * as Registry from '../registry'; import { dataStreamService } from '../../data_streams'; @@ -102,6 +103,13 @@ jest.mock('../archive', () => { }); jest.mock('../../audit_logging'); +jest.mock('../../utils/agentless', () => { + return { + isAgentlessEnabled: jest.fn(), + isOnlyAgentlessIntegration: jest.fn(), + }; +}); + const mockGetBundledPackageByPkgKey = jest.mocked(getBundledPackageByPkgKey); const mockedAuditLoggingService = jest.mocked(auditLoggingService); @@ -357,13 +365,72 @@ describe('install', () => { expect(response.status).toEqual('already_installed'); }); - // failing + describe('agentless', () => { + beforeEach(() => { + jest.mocked(appContextService.getConfig).mockClear(); + jest.spyOn(licenseService, 'hasAtLeast').mockClear(); + jest.mocked(isAgentlessEnabled).mockClear(); + jest.mocked(isOnlyAgentlessIntegration).mockClear(); + }); + + it('should not allow to install agentless only integration if agentless is not enabled', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + jest.mocked(isAgentlessEnabled).mockReturnValueOnce(false); + jest.mocked(isOnlyAgentlessIntegration).mockReturnValueOnce(true); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + expect(response.error).toBeDefined(); + expect(response.error!.message).toEqual( + 'test_package contains agentless policy templates, agentless is not available on this deployment' + ); + }); + + it('should allow to install agentless only integration if agentless is not enabled but using force flag', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + jest.mocked(isAgentlessEnabled).mockReturnValueOnce(false); + jest.mocked(isOnlyAgentlessIntegration).mockReturnValueOnce(true); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + force: true, + }); + expect(response.error).toBeUndefined(); + }); + + it('should allow to install agentless only integration if agentless is enabled', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + jest.mocked(isAgentlessEnabled).mockReturnValueOnce(true); + jest.mocked(isOnlyAgentlessIntegration).mockReturnValueOnce(true); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + expect(response.error).toBeUndefined(); + }); + }); + it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => { jest.mocked(appContextService.getConfig).mockReturnValueOnce({ internal: { fleetServerStandalone: true, }, } as any); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValueOnce(true); + jest.mocked(isOnlyAgentlessIntegration).mockReturnValueOnce(false); const response = await installPackage({ spaceId: DEFAULT_SPACE_ID, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 65f1a75f76f84..1ea6f29cad839 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -60,6 +60,7 @@ import { FleetUnauthorizedError, PackageNotFoundError, FleetTooManyRequestsError, + PackageInvalidDeploymentMode, } from '../../../errors'; import { PACKAGES_SAVED_OBJECT_TYPE, @@ -82,6 +83,8 @@ import { sendTelemetryEvents, UpdateEventType } from '../../upgrade_sender'; import { auditLoggingService } from '../../audit_logging'; import { getFilteredInstallPackages } from '../filtered_packages'; +import { isAgentlessEnabled, isOnlyAgentlessIntegration } from '../../utils/agentless'; + import { _stateMachineInstallPackage } from './install_state_machine/_state_machine_package_install'; import { formatVerificationResultForSO } from './package_verification'; @@ -507,6 +510,21 @@ async function installPackageFromRegistry({ }` ); } + + // only allow install of agentless packages if agentless is enabled, or if using force flag + const agentlessEnabled = isAgentlessEnabled(); + const agentlessOnlyIntegration = isOnlyAgentlessIntegration(packageInfo); + if (!agentlessEnabled && agentlessOnlyIntegration) { + if (!force) { + throw new PackageInvalidDeploymentMode( + `${pkgkey} contains agentless policy templates, agentless is not available on this deployment` + ); + } + logger.debug( + `${pkgkey} contains agentless policy templates, agentless is not available on this deployment but installing anyway due to force flag` + ); + } + return await installPackageWithStateMachine({ pkgName, pkgVersion, diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.ts b/x-pack/plugins/fleet/server/services/utils/agentless.ts index 4c27d583d9a79..c43f10db16b46 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.ts @@ -7,14 +7,15 @@ import { appContextService } from '..'; import type { FleetConfigType } from '../../config'; +export { isOnlyAgentlessIntegration } from '../../../common/services/agentless_policy_helper'; export const isAgentlessApiEnabled = () => { - const cloudSetup = appContextService.getCloud(); + const cloudSetup = appContextService.getCloud && appContextService.getCloud(); const isHosted = cloudSetup?.isCloudEnabled || cloudSetup?.isServerlessEnabled; return Boolean(isHosted && appContextService.getConfig()?.agentless?.enabled); }; export const isDefaultAgentlessPolicyEnabled = () => { - const cloudSetup = appContextService.getCloud(); + const cloudSetup = appContextService.getCloud && appContextService.getCloud(); return Boolean( cloudSetup?.isServerlessEnabled && appContextService.getExperimentalFeatures().agentless ); @@ -44,7 +45,7 @@ export const prependAgentlessApiBasePathToEndpoint = ( agentlessConfig: FleetConfigType['agentless'], endpoint: AgentlessApiEndpoints ) => { - const cloudSetup = appContextService.getCloud(); + const cloudSetup = appContextService.getCloud && appContextService.getCloud(); const endpointPrefix = cloudSetup?.isServerlessEnabled ? AGENTLESS_SERVERLESS_API_BASE_PATH : AGENTLESS_ESS_API_BASE_PATH; From 577599cf9f43c900062830fdb03787f7b419d7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= <alejandro.haro@elastic.co> Date: Wed, 16 Oct 2024 02:21:44 +0200 Subject: [PATCH 25/31] chore(): do not cancel ld-code-references jobs (#196388) --- .github/workflows/launchdarkly-code-references.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/launchdarkly-code-references.yml b/.github/workflows/launchdarkly-code-references.yml index 1034d25b29e85..23b877ce40d06 100644 --- a/.github/workflows/launchdarkly-code-references.yml +++ b/.github/workflows/launchdarkly-code-references.yml @@ -5,11 +5,6 @@ on: branches: - 'main' -# cancel in-flight workflow run if another push was triggered -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: launchDarklyCodeReferences: name: LaunchDarkly Code References From 267efdf31fe9ae314b0bed99bc23db5452a2aaa3 Mon Sep 17 00:00:00 2001 From: Ying Mao <ying.mao@elastic.co> Date: Tue, 15 Oct 2024 20:24:52 -0400 Subject: [PATCH 26/31] [Response Ops][Task Manager] Onboard 12.5% of ECH clusters to use `mget` task claiming (#196317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves https://github.com/elastic/response-ops-team/issues/239 ## Summary Deployed to cloud: deployment ID was `ab4e88d139f93d43024837d96144e7d4`. Since the deployment ID starts with an `a`, this should start with `mget` and I can see in the logs with the latest push that this is true <img width="2190" alt="Screenshot 2024-10-15 at 2 59 20 PM" src="https://github.com/user-attachments/assets/079bc4d8-365e-4ba6-b7a9-59fe506283d9"> Deployed to serverless: project ID was `d33d22a94ce246d091220eace2c4e4bb`. See in the logs: `Using claim strategy mget as configured for deployment d33d22a94ce246d091220eace2c4e4bb` Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../task_manager/server/config.test.ts | 3 - x-pack/plugins/task_manager/server/config.ts | 2 +- .../server/lib/set_claim_strategy.test.ts | 197 ++++++++++++++++++ .../server/lib/set_claim_strategy.ts | 76 +++++++ x-pack/plugins/task_manager/server/plugin.ts | 21 +- .../task_manager/server/polling_lifecycle.ts | 8 +- .../server/saved_objects/index.ts | 6 +- 7 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/lib/set_claim_strategy.test.ts create mode 100644 x-pack/plugins/task_manager/server/lib/set_claim_strategy.ts diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 34dd5f1c6fbff..aefbdaa9c8c56 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -14,7 +14,6 @@ describe('config validation', () => { Object { "allow_reading_invalid_state": true, "auto_calculate_default_ech_capacity": false, - "claim_strategy": "update_by_query", "discovery": Object { "active_nodes_lookback": "30s", "interval": 10000, @@ -77,7 +76,6 @@ describe('config validation', () => { Object { "allow_reading_invalid_state": true, "auto_calculate_default_ech_capacity": false, - "claim_strategy": "update_by_query", "discovery": Object { "active_nodes_lookback": "30s", "interval": 10000, @@ -138,7 +136,6 @@ describe('config validation', () => { Object { "allow_reading_invalid_state": true, "auto_calculate_default_ech_capacity": false, - "claim_strategy": "update_by_query", "discovery": Object { "active_nodes_lookback": "30s", "interval": 10000, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index f640ed2165f22..3eff1b507107c 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -202,7 +202,7 @@ export const configSchema = schema.object( max: 100, min: 1, }), - claim_strategy: schema.string({ defaultValue: CLAIM_STRATEGY_UPDATE_BY_QUERY }), + claim_strategy: schema.maybe(schema.string()), request_timeouts: requestTimeoutsConfig, auto_calculate_default_ech_capacity: schema.boolean({ defaultValue: false }), }, diff --git a/x-pack/plugins/task_manager/server/lib/set_claim_strategy.test.ts b/x-pack/plugins/task_manager/server/lib/set_claim_strategy.test.ts new file mode 100644 index 0000000000000..bb3d679299d33 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/set_claim_strategy.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CLAIM_STRATEGY_MGET, + CLAIM_STRATEGY_UPDATE_BY_QUERY, + DEFAULT_POLL_INTERVAL, + MGET_DEFAULT_POLL_INTERVAL, +} from '../config'; +import { mockLogger } from '../test_utils'; +import { setClaimStrategy } from './set_claim_strategy'; + +const getConfigWithoutClaimStrategy = () => ({ + discovery: { + active_nodes_lookback: '30s', + interval: 10000, + }, + kibanas_per_partition: 2, + capacity: 10, + max_attempts: 9, + allow_reading_invalid_state: false, + version_conflict_threshold: 80, + monitored_aggregated_stats_refresh_rate: 60000, + monitored_stats_health_verbose_log: { + enabled: false, + level: 'debug' as const, + warn_delayed_task_start_in_seconds: 60, + }, + monitored_stats_required_freshness: 4000, + monitored_stats_running_average_window: 50, + request_capacity: 1000, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, + }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, + unsafe: { + exclude_task_types: [], + authenticate_background_task_utilization: true, + }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, + worker_utilization_running_average_window: 5, + metrics_reset_interval: 3000, + request_timeouts: { + update_by_query: 1000, + }, + poll_interval: DEFAULT_POLL_INTERVAL, + auto_calculate_default_ech_capacity: false, +}); + +const logger = mockLogger(); + +const deploymentIdUpdateByQuery = 'd2f0e7c6bc464a9b8b16e5730b9c40f9'; +const deploymentIdMget = 'ab4e88d139f93d43024837d96144e7d4'; +describe('setClaimStrategy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + for (const isServerless of [true, false]) { + for (const isCloud of [true, false]) { + for (const deploymentId of [undefined, deploymentIdMget, deploymentIdUpdateByQuery]) { + for (const configuredStrategy of [CLAIM_STRATEGY_MGET, CLAIM_STRATEGY_UPDATE_BY_QUERY]) { + test(`should return config as is when claim strategy is already defined: isServerless=${isServerless}, isCloud=${isCloud}, deploymentId=${deploymentId}`, () => { + const config = { + ...getConfigWithoutClaimStrategy(), + claim_strategy: configuredStrategy, + }; + + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud, + isServerless, + deploymentId, + }); + + expect(returnedConfig).toStrictEqual(config); + if (deploymentId) { + expect(logger.info).toHaveBeenCalledWith( + `Using claim strategy ${configuredStrategy} as configured for deployment ${deploymentId}` + ); + } else { + expect(logger.info).toHaveBeenCalledWith( + `Using claim strategy ${configuredStrategy} as configured` + ); + } + }); + } + } + } + } + + for (const isCloud of [true, false]) { + for (const deploymentId of [undefined, deploymentIdMget, deploymentIdUpdateByQuery]) { + test(`should set claim strategy to mget if in serverless: isCloud=${isCloud}, deploymentId=${deploymentId}`, () => { + const config = getConfigWithoutClaimStrategy(); + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud, + isServerless: true, + deploymentId, + }); + + expect(returnedConfig.claim_strategy).toBe(CLAIM_STRATEGY_MGET); + expect(returnedConfig.poll_interval).toBe(MGET_DEFAULT_POLL_INTERVAL); + + if (deploymentId) { + expect(logger.info).toHaveBeenCalledWith( + `Setting claim strategy to mget for serverless deployment ${deploymentId}` + ); + } else { + expect(logger.info).toHaveBeenCalledWith(`Setting claim strategy to mget`); + } + }); + } + } + + test(`should set claim strategy to update_by_query if not cloud and not serverless`, () => { + const config = getConfigWithoutClaimStrategy(); + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud: false, + isServerless: false, + }); + + expect(returnedConfig.claim_strategy).toBe(CLAIM_STRATEGY_UPDATE_BY_QUERY); + expect(returnedConfig.poll_interval).toBe(DEFAULT_POLL_INTERVAL); + + expect(logger.info).not.toHaveBeenCalled(); + }); + + test(`should set claim strategy to update_by_query if cloud and not serverless with undefined deploymentId`, () => { + const config = getConfigWithoutClaimStrategy(); + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud: true, + isServerless: false, + }); + + expect(returnedConfig.claim_strategy).toBe(CLAIM_STRATEGY_UPDATE_BY_QUERY); + expect(returnedConfig.poll_interval).toBe(DEFAULT_POLL_INTERVAL); + + expect(logger.info).not.toHaveBeenCalled(); + }); + + test(`should set claim strategy to update_by_query if cloud and not serverless and deploymentId does not start with a or b`, () => { + const config = getConfigWithoutClaimStrategy(); + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud: true, + isServerless: false, + deploymentId: deploymentIdUpdateByQuery, + }); + + expect(returnedConfig.claim_strategy).toBe(CLAIM_STRATEGY_UPDATE_BY_QUERY); + expect(returnedConfig.poll_interval).toBe(DEFAULT_POLL_INTERVAL); + + expect(logger.info).toHaveBeenCalledWith( + `Setting claim strategy to update_by_query for deployment ${deploymentIdUpdateByQuery}` + ); + }); + + test(`should set claim strategy to mget if cloud and not serverless and deploymentId starts with a or b`, () => { + const config = getConfigWithoutClaimStrategy(); + const returnedConfig = setClaimStrategy({ + config, + logger, + isCloud: true, + isServerless: false, + deploymentId: deploymentIdMget, + }); + + expect(returnedConfig.claim_strategy).toBe(CLAIM_STRATEGY_MGET); + expect(returnedConfig.poll_interval).toBe(MGET_DEFAULT_POLL_INTERVAL); + + expect(logger.info).toHaveBeenCalledWith( + `Setting claim strategy to mget for deployment ${deploymentIdMget}` + ); + }); +}); diff --git a/x-pack/plugins/task_manager/server/lib/set_claim_strategy.ts b/x-pack/plugins/task_manager/server/lib/set_claim_strategy.ts new file mode 100644 index 0000000000000..52d71d25c7387 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/set_claim_strategy.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { + CLAIM_STRATEGY_MGET, + CLAIM_STRATEGY_UPDATE_BY_QUERY, + DEFAULT_POLL_INTERVAL, + MGET_DEFAULT_POLL_INTERVAL, + TaskManagerConfig, +} from '../config'; + +interface SetClaimStrategyOpts { + config: TaskManagerConfig; + deploymentId?: string; + isServerless: boolean; + isCloud: boolean; + logger: Logger; +} + +export function setClaimStrategy(opts: SetClaimStrategyOpts): TaskManagerConfig { + // if the claim strategy is already defined, return immediately + if (opts.config.claim_strategy) { + opts.logger.info( + `Using claim strategy ${opts.config.claim_strategy} as configured${ + opts.deploymentId ? ` for deployment ${opts.deploymentId}` : '' + }` + ); + return opts.config; + } + + if (opts.isServerless) { + // use mget for serverless + opts.logger.info( + `Setting claim strategy to mget${ + opts.deploymentId ? ` for serverless deployment ${opts.deploymentId}` : '' + }` + ); + return { + ...opts.config, + claim_strategy: CLAIM_STRATEGY_MGET, + poll_interval: MGET_DEFAULT_POLL_INTERVAL, + }; + } + + let defaultToMget = false; + + if (opts.isCloud && !opts.isServerless && opts.deploymentId) { + defaultToMget = opts.deploymentId.startsWith('a') || opts.deploymentId.startsWith('b'); + if (defaultToMget) { + opts.logger.info(`Setting claim strategy to mget for deployment ${opts.deploymentId}`); + } else { + opts.logger.info( + `Setting claim strategy to update_by_query for deployment ${opts.deploymentId}` + ); + } + } + + if (defaultToMget) { + return { + ...opts.config, + claim_strategy: CLAIM_STRATEGY_MGET, + poll_interval: MGET_DEFAULT_POLL_INTERVAL, + }; + } + + return { + ...opts.config, + claim_strategy: CLAIM_STRATEGY_UPDATE_BY_QUERY, + poll_interval: DEFAULT_POLL_INTERVAL, + }; +} diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 45960195be216..61731c4ae82f3 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -18,7 +18,7 @@ import { ServiceStatusLevels, CoreStatus, } from '@kbn/core/server'; -import type { CloudStart } from '@kbn/cloud-plugin/server'; +import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import { registerDeleteInactiveNodesTaskDefinition, scheduleDeleteInactiveNodesTaskDefinition, @@ -45,6 +45,7 @@ import { metricsStream, Metrics } from './metrics'; import { TaskManagerMetricsCollector } from './metrics/task_metrics_collector'; import { TaskPartitioner } from './lib/task_partitioner'; import { getDefaultCapacity } from './lib/get_default_capacity'; +import { setClaimStrategy } from './lib/set_claim_strategy'; export interface TaskManagerSetupContract { /** @@ -126,10 +127,18 @@ export class TaskManagerPlugin public setup( core: CoreSetup<TaskManagerStartContract, unknown>, - plugins: { usageCollection?: UsageCollectionSetup } + plugins: { cloud?: CloudSetup; usageCollection?: UsageCollectionSetup } ): TaskManagerSetupContract { this.elasticsearchAndSOAvailability$ = getElasticsearchAndSOAvailability(core.status.core$); + this.config = setClaimStrategy({ + config: this.config, + deploymentId: plugins.cloud?.deploymentId, + isServerless: this.initContext.env.packageInfo.buildFlavor === 'serverless', + isCloud: plugins.cloud?.isCloudEnabled ?? false, + logger: this.logger, + }); + core.metrics .getOpsMetrics$() .pipe(distinctUntilChanged()) @@ -137,7 +146,7 @@ export class TaskManagerPlugin this.heapSizeLimit = metrics.process.memory.heap.size_limit; }); - setupSavedObjects(core.savedObjects, this.config); + setupSavedObjects(core.savedObjects); this.taskManagerId = this.initContext.env.instanceUuid; if (!this.taskManagerId) { @@ -301,9 +310,9 @@ export class TaskManagerPlugin this.config!.claim_strategy } isBackgroundTaskNodeOnly=${this.isNodeBackgroundTasksOnly()} heapSizeLimit=${ this.heapSizeLimit - } defaultCapacity=${defaultCapacity} autoCalculateDefaultEchCapacity=${ - this.config.auto_calculate_default_ech_capacity - }` + } defaultCapacity=${defaultCapacity} pollingInterval=${ + this.config!.poll_interval + } autoCalculateDefaultEchCapacity=${this.config.auto_calculate_default_ech_capacity}` ); const managedConfiguration = createManagedConfiguration({ diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index 7d8be75c2330c..3cb6802f43eb1 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -14,7 +14,7 @@ import type { Logger, ExecutionContextStart } from '@kbn/core/server'; import { Result, asErr, mapErr, asOk, map, mapOk } from './lib/result_type'; import { ManagedConfiguration } from './lib/create_managed_configuration'; -import { TaskManagerConfig, CLAIM_STRATEGY_UPDATE_BY_QUERY } from './config'; +import { CLAIM_STRATEGY_UPDATE_BY_QUERY, TaskManagerConfig } from './config'; import { TaskMarkRunning, @@ -141,7 +141,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter<TaskLifecycleEven this.pool = new TaskPool({ logger, - strategy: config.claim_strategy, + strategy: config.claim_strategy!, capacity$: capacityConfiguration$, definitions: this.definitions, }); @@ -149,7 +149,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter<TaskLifecycleEven this.taskClaiming = new TaskClaiming({ taskStore, - strategy: config.claim_strategy, + strategy: config.claim_strategy!, maxAttempts: config.max_attempts, excludedTaskTypes: config.unsafe.exclude_task_types, definitions, @@ -238,7 +238,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter<TaskLifecycleEven usageCounter: this.usageCounter, config: this.config, allowReadingInvalidState: this.config.allow_reading_invalid_state, - strategy: this.config.claim_strategy, + strategy: this.config.claim_strategy!, getPollInterval: () => this.currentPollInterval, }); }; diff --git a/x-pack/plugins/task_manager/server/saved_objects/index.ts b/x-pack/plugins/task_manager/server/saved_objects/index.ts index dc1cd97677767..5c0f8b9a0776d 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/index.ts @@ -9,7 +9,6 @@ import type { SavedObjectsServiceSetup } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { backgroundTaskNodeMapping, taskMappings } from './mappings'; import { getMigrations } from './migrations'; -import { TaskManagerConfig } from '../config'; import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; import { TASK_MANAGER_INDEX } from '../constants'; import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions'; @@ -17,10 +16,7 @@ import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_vers export const TASK_SO_NAME = 'task'; export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node'; -export function setupSavedObjects( - savedObjects: SavedObjectsServiceSetup, - config: TaskManagerConfig -) { +export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) { savedObjects.registerType({ name: TASK_SO_NAME, namespaceType: 'agnostic', From 5ae7a61d935e3c1778ee830a5c1ee5055abf44a0 Mon Sep 17 00:00:00 2001 From: Charlotte Alexandra Wilson <CAWilson94@users.noreply.github.com> Date: Wed, 16 Oct 2024 01:29:35 +0100 Subject: [PATCH 27/31] Feature/remove asset criticality flag (#196270) ## Summary It removes the asset criticality advanced setting, which enables the feature by default for all users. Deleted settings: ![Screenshot 2024-10-15 at 14 54 48](https://github.com/user-attachments/assets/103c3f04-fd7e-45cf-ac74-93e1eef341fa) ### How to test it? * Start Kibana with security data * Inside security solution / manage, you should be able to find the Asset Criticality page ![Screenshot 2024-10-15 at 14 57 14](https://github.com/user-attachments/assets/7ddcee91-ad76-4d8f-b14a-bacc4ba31172) * You should see the asset critically section when opening an entity flyout (explore or host page) <img width="400" src="https://github.com/user-attachments/assets/3a9ee545-566c-4687-af16-f31bd93bdc20" /> * The risk score should be updated if you update an entity's asset criticality. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: machadoum <pablo.nevesmachado@elastic.co> Co-authored-by: jaredburgettelastic <jared.burgett@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../settings/security_project/index.ts | 1 - .../security_solution/common/constants.ts | 3 - .../use_asset_criticality.test.ts | 9 --- .../use_asset_criticality.ts | 10 ++-- .../tabs/risk_inputs/risk_inputs_tab.tsx | 8 +-- .../components/risk_summary_flyout/common.tsx | 60 ++++++++----------- .../risk_summary_flyout/risk_summary.test.tsx | 41 ------------- .../risk_summary_flyout/risk_summary.tsx | 15 +---- .../pages/entity_store_management_page.tsx | 10 +--- .../hosts/components/hosts_table/columns.tsx | 37 ++++++------ .../components/hosts_table/index.test.tsx | 25 -------- .../hosts/components/hosts_table/index.tsx | 18 +----- .../users/components/all_users/index.test.tsx | 6 +- .../users/components/all_users/index.tsx | 46 +++++++------- .../utils/enrichments/index.test.ts | 6 -- .../rule_types/utils/enrichments/index.ts | 54 +++++++---------- .../asset_criticality_service.mock.ts | 1 - .../asset_criticality_service.ts | 4 -- .../asset_criticality/routes/bulk_upload.ts | 3 - .../asset_criticality/routes/delete.ts | 3 - .../asset_criticality/routes/get.ts | 3 - .../asset_criticality/routes/list.ts | 3 - .../asset_criticality/routes/privileges.ts | 4 -- .../asset_criticality/routes/status.ts | 3 - .../asset_criticality/routes/upload_csv.ts | 3 - .../asset_criticality/routes/upsert.ts | 3 - .../risk_score/calculate_risk_scores.ts | 7 --- .../security_solution/server/ui_settings.ts | 19 ------ .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../execution_logic/custom_query.ts | 5 -- .../execution_logic/eql.ts | 9 +-- .../execution_logic/eql_alert_suppression.ts | 10 +--- .../execution_logic/esql.ts | 5 -- .../execution_logic/esql_suppression.ts | 5 -- .../execution_logic/indicator_match.ts | 5 -- .../indicator_match_alert_suppression.ts | 5 -- .../execution_logic/machine_learning.ts | 9 +-- .../machine_learning_alert_suppression.ts | 10 +--- .../execution_logic/new_terms.ts | 6 +- .../new_terms_alert_suppression.ts | 5 -- .../execution_logic/threshold.ts | 5 -- .../threshold_alert_suppression.ts | 5 -- .../asset_criticality.ts | 55 ----------------- .../asset_criticality_csv_upload.ts | 15 ----- .../asset_criticality_privileges.ts | 9 +-- .../risk_score_entity_calculation.ts | 5 -- .../risk_score_preview.ts | 6 -- .../utils/asset_criticality.ts | 40 ------------- .../asset_criticality_upload_page.cy.ts | 2 - .../e2e/entity_analytics/entity_flyout.cy.ts | 2 - .../api_calls/kibana_advanced_settings.ts | 5 -- 53 files changed, 106 insertions(+), 528 deletions(-) diff --git a/packages/serverless/settings/security_project/index.ts b/packages/serverless/settings/security_project/index.ts index dbbf6e506eda8..0fd820640bb98 100644 --- a/packages/serverless/settings/security_project/index.ts +++ b/packages/serverless/settings/security_project/index.ts @@ -23,5 +23,4 @@ export const SECURITY_PROJECT_SETTINGS = [ settings.SECURITY_SOLUTION_NEWS_FEED_URL_ID, settings.SECURITY_SOLUTION_ENABLE_NEWS_FEED_ID, settings.SECURITY_SOLUTION_DEFAULT_ALERT_TAGS_KEY, - settings.SECURITY_SOLUTION_ENABLE_ASSET_CRITICALITY_SETTING, ]; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d4cb8f088df88..877214641dc1e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -196,9 +196,6 @@ export const EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING = export const EXTENDED_RULE_EXECUTION_LOGGING_MIN_LEVEL_SETTING = 'securitySolution:extendedRuleExecutionLoggingMinLevel' as const; -/** This Kibana Advanced Setting allows users to enable/disable the Asset Criticality feature */ -export const ENABLE_ASSET_CRITICALITY_SETTING = 'securitySolution:enableAssetCriticality' as const; - /** This Kibana Advanced Setting allows users to exclude selected data tiers from search during rule execution */ export const EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION = 'securitySolution:excludedDataTiersForRuleExecution' as const; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.test.ts index bd6a6aae0604b..d4671a5bc628a 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.test.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.test.ts @@ -60,15 +60,6 @@ describe('useAssetCriticality', () => { expect(mockFetchAssetCriticalityPrivileges).toHaveBeenCalled(); }); - - it('does not call privileges API when UI Settings is disabled', async () => { - mockUseHasSecurityCapability.mockReturnValue(true); - mockUseUiSettings.mockReturnValue([false]); - - await renderQuery(() => useAssetCriticalityPrivileges('test_entity_name'), 'isSuccess'); - - expect(mockFetchAssetCriticalityPrivileges).not.toHaveBeenCalled(); - }); }); describe('useAssetCriticalityData', () => { diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts index 9bd67dfed731e..d5ecde239f35a 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts @@ -7,11 +7,9 @@ import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; import type { EntityAnalyticsPrivileges } from '../../../../common/api/entity_analytics'; import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../common/constants'; import { useHasSecurityCapability } from '../../../helper_hooks'; import type { AssetCriticalityRecord } from '../../../../common/api/entity_analytics/asset_criticality'; import type { AssetCriticality, DeleteAssetCriticalityResponse } from '../../api/api'; @@ -34,12 +32,12 @@ export const useAssetCriticalityPrivileges = ( ): UseQueryResult<EntityAnalyticsPrivileges, SecurityAppError> => { const { fetchAssetCriticalityPrivileges } = useEntityAnalyticsRoutes(); const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); - const isEnabled = isAssetCriticalityEnabled && hasEntityAnalyticsCapability; return useQuery({ - queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, queryKey, isEnabled], - queryFn: isEnabled ? fetchAssetCriticalityPrivileges : () => nonAuthorizedResponse, + queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, queryKey, hasEntityAnalyticsCapability], + queryFn: hasEntityAnalyticsCapability + ? fetchAssetCriticalityPrivileges + : () => nonAuthorizedResponse, }); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx index 7f59ac7efbf42..78010434ee593 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx @@ -10,7 +10,6 @@ import { EuiSpacer, EuiInMemoryTable, EuiTitle, EuiCallOut } from '@elastic/eui' import type { ReactNode } from 'react'; import React, { useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { get } from 'lodash/fp'; @@ -24,7 +23,6 @@ import type { UseRiskContributingAlertsResult, } from '../../../../hooks/use_risk_contributing_alerts'; import { useRiskContributingAlerts } from '../../../../hooks/use_risk_contributing_alerts'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../../../common/constants'; import { PreferenceFormattedDate } from '../../../../../common/components/formatted_date'; import { useRiskScore } from '../../../../api/hooks/use_risk_score'; @@ -177,8 +175,6 @@ export const RiskInputsTab = ({ entityType, entityName, scopeId }: RiskInputsTab [isPreviewEnabled, scopeId] ); - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); - if (riskScoreError) { return ( <EuiCallOut @@ -229,9 +225,7 @@ export const RiskInputsTab = ({ entityType, entityName, scopeId }: RiskInputsTab return ( <> - {isAssetCriticalityEnabled && ( - <ContextsSection loading={loadingRiskScore} riskScore={riskScore} /> - )} + <ContextsSection loading={loadingRiskScore} riskScore={riskScore} /> <EuiSpacer size="m" /> {riskInputsAlertSection} </> diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/common.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/common.tsx index ccf98e10a76af..1f9832b79654c 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/common.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/common.tsx @@ -24,9 +24,7 @@ interface EntityData { risk: RiskStats; } -export const buildColumns: (showFooter: boolean) => Array<EuiBasicTableColumn<TableItem>> = ( - showFooter -) => [ +export const buildColumns: () => Array<EuiBasicTableColumn<TableItem>> = () => [ { field: 'category', name: ( @@ -38,12 +36,12 @@ export const buildColumns: (showFooter: boolean) => Array<EuiBasicTableColumn<Ta truncateText: false, mobileOptions: { show: true }, sortable: true, - footer: showFooter ? ( + footer: ( <FormattedMessage id="xpack.securitySolution.flyout.entityDetails.categoryColumnFooterLabel" defaultMessage="Result" /> - ) : undefined, + ), }, { field: 'score', @@ -59,12 +57,11 @@ export const buildColumns: (showFooter: boolean) => Array<EuiBasicTableColumn<Ta dataType: 'number', align: 'right', render: formatRiskScore, - footer: (props) => - showFooter ? ( - <span data-test-subj="risk-summary-result-score"> - {formatRiskScore(sumBy((i) => i.score, props.items))} - </span> - ) : undefined, + footer: (props) => ( + <span data-test-subj="risk-summary-result-score"> + {formatRiskScore(sumBy((i) => i.score, props.items))} + </span> + ), }, { field: 'count', @@ -79,19 +76,15 @@ export const buildColumns: (showFooter: boolean) => Array<EuiBasicTableColumn<Ta sortable: true, dataType: 'number', align: 'right', - footer: (props) => - showFooter ? ( - <span data-test-subj="risk-summary-result-count"> - {sumBy((i) => i.count ?? 0, props.items)} - </span> - ) : undefined, + footer: (props) => ( + <span data-test-subj="risk-summary-result-count"> + {sumBy((i) => i.count ?? 0, props.items)} + </span> + ), }, ]; -export const getItems: ( - entityData: EntityData | undefined, - isAssetCriticalityEnabled: boolean -) => TableItem[] = (entityData, isAssetCriticalityEnabled) => { +export const getItems: (entityData: EntityData | undefined) => TableItem[] = (entityData) => { return [ { category: i18n.translate('xpack.securitySolution.flyout.entityDetails.alertsGroupLabel', { @@ -100,20 +93,17 @@ export const getItems: ( score: entityData?.risk.category_1_score ?? 0, count: entityData?.risk.category_1_count ?? 0, }, - ...(isAssetCriticalityEnabled - ? [ - { - category: i18n.translate( - 'xpack.securitySolution.flyout.entityDetails.assetCriticalityGroupLabel', - { - defaultMessage: 'Asset Criticality', - } - ), - score: entityData?.risk.category_2_score ?? 0, - count: undefined, - }, - ] - : []), + + { + category: i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.assetCriticalityGroupLabel', + { + defaultMessage: 'Asset Criticality', + } + ), + score: entityData?.risk.category_2_score ?? 0, + count: undefined, + }, ]; }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx index aa0eab9902520..494cfd5c16b2a 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx @@ -27,53 +27,12 @@ jest.mock('../../../common/components/visualization_actions/visualization_embedd mockVisualizationEmbeddable(props), })); -const mockUseUiSetting = jest.fn().mockReturnValue([false]); - -jest.mock('@kbn/kibana-react-plugin/public', () => { - const original = jest.requireActual('@kbn/kibana-react-plugin/public'); - return { - ...original, - useUiSetting$: () => mockUseUiSetting(), - }; -}); - describe('FlyoutRiskSummary', () => { beforeEach(() => { mockVisualizationEmbeddable.mockClear(); }); - it('renders risk summary table with alerts only', () => { - const { getByTestId, queryByTestId } = render( - <TestProviders> - <FlyoutRiskSummary - riskScoreData={mockHostRiskScoreState} - queryId={'testQuery'} - openDetailsPanel={() => {}} - recalculatingScore={false} - /> - </TestProviders> - ); - - expect(getByTestId('risk-summary-table')).toBeInTheDocument(); - - // Alerts - expect(getByTestId('risk-summary-table')).toHaveTextContent( - `${mockHostRiskScoreState.data?.[0].host.risk.category_1_count}` - ); - - // Context - expect(getByTestId('risk-summary-table')).not.toHaveTextContent( - `${mockHostRiskScoreState.data?.[0].host.risk.category_2_count}` - ); - - // Result row doesn't exist if alerts are the only category - expect(queryByTestId('risk-summary-result-count')).not.toBeInTheDocument(); - expect(queryByTestId('risk-summary-result-score')).not.toBeInTheDocument(); - }); - it('renders risk summary table with context and totals', () => { - mockUseUiSetting.mockReturnValue([true]); - const { getByTestId } = render( <TestProviders> <FlyoutRiskSummary diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx index b0d988eaeac1a..fee62f34dfdee 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx @@ -23,8 +23,7 @@ import { euiThemeVars } from '@kbn/ui-theme'; import dateMath from '@kbn/datemath'; import { i18n } from '@kbn/i18n'; import { ExpandablePanel } from '@kbn/security-solution-common'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../common/constants'; -import { useKibana, useUiSetting$ } from '../../../common/lib/kibana/kibana_react'; +import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; @@ -82,17 +81,9 @@ const FlyoutRiskSummaryComponent = <T extends RiskScoreEntity>({ const xsFontSize = useEuiFontSize('xxs').fontSize; - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); + const columns = useMemo(() => buildColumns(), []); - const columns = useMemo( - () => buildColumns(isAssetCriticalityEnabled), - [isAssetCriticalityEnabled] - ); - - const rows = useMemo( - () => getItems(entityData, isAssetCriticalityEnabled), - [entityData, isAssetCriticalityEnabled] - ); + const rows = useMemo(() => getItems(entityData), [entityData]); const onToggle = useCallback( (isOpen: boolean) => { diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx index 0e09e5ceac3ef..53abf222d39e4 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_store_management_page.tsx @@ -31,8 +31,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useEntityEngineStatus } from '../components/entity_store/hooks/use_entity_engine_status'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { ASSET_CRITICALITY_INDEX_PATTERN } from '../../../common/entity_analytics/asset_criticality'; -import { useUiSetting$, useKibana } from '../../common/lib/kibana'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../common/constants'; +import { useKibana } from '../../common/lib/kibana'; import { AssetCriticalityFileUploader } from '../components/asset_criticality_file_uploader/asset_criticality_file_uploader'; import { useAssetCriticalityPrivileges } from '../components/asset_criticality/use_asset_criticality'; import { useHasSecurityCapability } from '../../helper_hooks'; @@ -50,7 +49,6 @@ const entityStoreInstallingStatuses = ['installing', 'loading']; export const EntityStoreManagementPage = () => { const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); const isEntityStoreFeatureFlagDisabled = useIsExperimentalFeatureEnabled('entityStoreDisabled'); - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); const { data: assetCriticalityPrivileges, error: assetCriticalityPrivilegesError, @@ -110,10 +108,7 @@ export const EntityStoreManagementPage = () => { const errorMessage = assetCriticalityPrivilegesError?.body.message ?? ( <FormattedMessage id="xpack.securitySolution.entityAnalytics.assetCriticalityUploadPage.advancedSettingDisabledMessage" - defaultMessage='Please enable "{ENABLE_ASSET_CRITICALITY_SETTING}" in advanced settings to access this functionality.' - values={{ - ENABLE_ASSET_CRITICALITY_SETTING, - }} + defaultMessage="The don't have privileges to access Asset Criticality feature. Contact your administrator for further assistance." /> ); @@ -218,7 +213,6 @@ export const EntityStoreManagementPage = () => { const FileUploadSection: React.FC = () => { if ( !hasEntityAnalyticsCapability || - !isAssetCriticalityEnabled || assetCriticalityPrivilegesError?.body.status_code === 403 ) { return <AssetCriticalityIssueCallout />; diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx index 6f8e3ad587a0d..d4f2791f4a314 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx @@ -27,8 +27,7 @@ import { ENTITY_RISK_LEVEL } from '../../../../entity_analytics/components/risk_ export const getHostsColumns = ( showRiskColumn: boolean, - dispatchSeverityUpdate: (s: RiskSeverity) => void, - isAssetCriticalityEnabled: boolean + dispatchSeverityUpdate: (s: RiskSeverity) => void ): HostsTableColumns => { const columns: HostsTableColumns = [ { @@ -166,24 +165,22 @@ export const getHostsColumns = ( }); } - if (isAssetCriticalityEnabled) { - columns.push({ - field: 'node.criticality', - name: i18n.ASSET_CRITICALITY, - truncateText: false, - mobileOptions: { show: true }, - sortable: false, - render: (assetCriticality: CriticalityLevelWithUnassigned) => { - if (!assetCriticality) return getEmptyTagValue(); - return ( - <AssetCriticalityBadge - criticalityLevel={assetCriticality} - css={{ verticalAlign: 'middle' }} - /> - ); - }, - }); - } + columns.push({ + field: 'node.criticality', + name: i18n.ASSET_CRITICALITY, + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (assetCriticality: CriticalityLevelWithUnassigned) => { + if (!assetCriticality) return getEmptyTagValue(); + return ( + <AssetCriticalityBadge + criticalityLevel={assetCriticality} + css={{ verticalAlign: 'middle' }} + /> + ); + }, + }); return columns; }; diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx index 8d20fed91a66a..606bd77ebcc45 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx @@ -180,31 +180,6 @@ describe('Hosts Table', () => { expect(queryByTestId('tableHeaderCell_node.criticality_5')).toBeInTheDocument(); }); - test('it does not render "Asset Criticality" column when Asset Criticality is not enabled in Kibana settings', () => { - mockUseMlCapabilities.mockReturnValue({ isPlatinumOrTrialLicense: true }); - mockUseHasSecurityCapability.mockReturnValue(true); - mockUseUiSetting.mockReturnValue([false]); - - const { queryByTestId } = render( - <TestProviders store={store}> - <HostsTable - id="hostsQuery" - isInspect={false} - loading={false} - data={mockData} - totalCount={0} - fakeTotalCount={-1} - setQuerySkip={jest.fn()} - showMorePagesIndicator={false} - loadPage={loadPage} - type={hostsModel.HostsType.page} - /> - </TestProviders> - ); - - expect(queryByTestId('tableHeaderCell_node.criticality_5')).not.toBeInTheDocument(); - }); - describe('Sorting on Table', () => { let wrapper: ReturnType<typeof mount>; diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx index 20299d564d587..e7a9808461beb 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import type { HostEcs, OsEcs } from '@kbn/securitysolution-ecs'; -import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; import { HostsFields } from '../../../../../common/api/search_strategy/hosts/model/sort'; import type { @@ -30,10 +29,7 @@ import type { HostsSortField, } from '../../../../../common/search_strategy/security_solution/hosts'; import type { Direction, RiskSeverity } from '../../../../../common/search_strategy'; -import { - ENABLE_ASSET_CRITICALITY_SETTING, - SecurityPageName, -} from '../../../../../common/constants'; +import { SecurityPageName } from '../../../../../common/constants'; import { HostsTableType } from '../../store/model'; import { useNavigateTo } from '../../../../common/lib/kibana/hooks'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; @@ -160,21 +156,13 @@ const HostsTableComponent: React.FC<HostsTableProps> = ({ [dispatch, navigateTo, type] ); - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); - const hostsColumns = useMemo( () => getHostsColumns( isPlatinumOrTrialLicense && hasEntityAnalyticsCapability, - dispatchSeverityUpdate, - isAssetCriticalityEnabled + dispatchSeverityUpdate ), - [ - dispatchSeverityUpdate, - isPlatinumOrTrialLicense, - hasEntityAnalyticsCapability, - isAssetCriticalityEnabled, - ] + [dispatchSeverityUpdate, isPlatinumOrTrialLicense, hasEntityAnalyticsCapability] ); const sorting = useMemo(() => getSorting(sortField, direction), [sortField, direction]); diff --git a/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.test.tsx b/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.test.tsx index da54aa8aa05c8..eb8ce33bf76ff 100644 --- a/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.test.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.test.tsx @@ -50,7 +50,7 @@ describe('Users Table Component', () => { ); expect(getByTestId('table-allUsers-loading-false')).toBeInTheDocument(); - expect(getAllByRole('columnheader').length).toBe(3); + expect(getAllByRole('columnheader').length).toBe(4); expect(getByText(userName)).toBeInTheDocument(); }); @@ -108,7 +108,7 @@ describe('Users Table Component', () => { </TestProviders> ); - expect(getAllByRole('columnheader').length).toBe(4); + expect(getAllByRole('columnheader').length).toBe(5); expect(getByText('Critical')).toBeInTheDocument(); }); @@ -142,7 +142,7 @@ describe('Users Table Component', () => { </TestProviders> ); - expect(getAllByRole('columnheader').length).toBe(3); + expect(getAllByRole('columnheader').length).toBe(4); expect(queryByText('Critical')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.tsx b/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.tsx index 92303187f231a..dccb3d89f4f65 100644 --- a/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/components/all_users/index.tsx @@ -9,7 +9,6 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiLink, EuiText } from '@elastic/eui'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../../common/constants'; import { AssetCriticalityBadge } from '../../../../entity_analytics/components/asset_criticality'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; @@ -40,7 +39,7 @@ import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml import { VIEW_USERS_BY_SEVERITY } from '../../../../entity_analytics/components/user_risk_score_table/translations'; import { SecurityPageName } from '../../../../app/types'; import { UsersTableType } from '../../store/model'; -import { useNavigateTo, useUiSetting$ } from '../../../../common/lib/kibana'; +import { useNavigateTo } from '../../../../common/lib/kibana'; const tableType = usersModel.UsersTableType.allUsers; @@ -78,8 +77,7 @@ const rowItems: ItemsPerRow[] = [ const getUsersColumns = ( showRiskColumn: boolean, - dispatchSeverityUpdate: (s: RiskSeverity) => void, - isAssetCriticalityEnabled: boolean + dispatchSeverityUpdate: (s: RiskSeverity) => void ): UsersTableColumns => { const columns: UsersTableColumns = [ { @@ -148,24 +146,22 @@ const getUsersColumns = ( }); } - if (isAssetCriticalityEnabled) { - columns.push({ - field: 'criticality', - name: i18n.ASSET_CRITICALITY, - truncateText: false, - mobileOptions: { show: true }, - sortable: false, - render: (assetCriticality: CriticalityLevelWithUnassigned) => { - if (!assetCriticality) return getEmptyTagValue(); - return ( - <AssetCriticalityBadge - criticalityLevel={assetCriticality} - css={{ verticalAlign: 'middle' }} - /> - ); - }, - }); - } + columns.push({ + field: 'criticality', + name: i18n.ASSET_CRITICALITY, + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (assetCriticality: CriticalityLevelWithUnassigned) => { + if (!assetCriticality) return getEmptyTagValue(); + return ( + <AssetCriticalityBadge + criticalityLevel={assetCriticality} + css={{ verticalAlign: 'middle' }} + /> + ); + }, + }); return columns; }; @@ -246,11 +242,9 @@ const UsersTableComponent: React.FC<UsersTableProps> = ({ [dispatch, navigateTo] ); - const [isAssetCriticalityEnabled] = useUiSetting$<boolean>(ENABLE_ASSET_CRITICALITY_SETTING); const columns = useMemo( - () => - getUsersColumns(isPlatinumOrTrialLicense, dispatchSeverityUpdate, isAssetCriticalityEnabled), - [isPlatinumOrTrialLicense, dispatchSeverityUpdate, isAssetCriticalityEnabled] + () => getUsersColumns(isPlatinumOrTrialLicense, dispatchSeverityUpdate), + [isPlatinumOrTrialLicense, dispatchSeverityUpdate] ); return ( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.test.ts index e3967bd2e0040..415128d1d9f0d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.test.ts @@ -15,7 +15,6 @@ import { createAlert } from './__mocks__/alerts'; import { isIndexExist } from './utils/is_index_exist'; import { allowedExperimentalValues } from '../../../../../../common'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../../../common/constants'; jest.mock('./search_enrichments', () => ({ searchEnrichments: jest.fn(), @@ -190,11 +189,6 @@ describe('enrichEvents', () => { // enable for asset criticality mockIsIndexExist.mockImplementation(() => true); - // enable asset criticality settings - alertServices.uiSettingsClient.get.mockImplementation((key) => - Promise.resolve(key === ENABLE_ASSET_CRITICALITY_SETTING) - ); - const enrichedEvents = await enrichEvents({ logger: ruleExecutionLogger, services: alertServices, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.ts index 0ed95e59e5542..7f0c797bc6743 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../../../common/constants'; import { createHostRiskEnrichments } from './enrichment_by_type/host_risk'; import { createUserRiskEnrichments } from './enrichment_by_type/user_risk'; @@ -22,10 +21,7 @@ import type { } from './types'; import { applyEnrichmentsToEvents } from './utils/transforms'; import { isIndexExist } from './utils/is_index_exist'; -import { - getHostRiskIndex, - getUserRiskIndex, -} from '../../../../../../common/search_strategy/security_solution/risk_score/common'; +import { getHostRiskIndex, getUserRiskIndex } from '../../../../../../common/search_strategy'; export const enrichEvents: EnrichEventsFunction = async ({ services, @@ -39,10 +35,6 @@ export const enrichEvents: EnrichEventsFunction = async ({ logger.debug('Alert enrichments started'); const isNewRiskScoreModuleAvailable = experimentalFeatures?.riskScoringRoutesEnabled ?? false; - const { uiSettingsClient } = services; - const isAssetCriticalityEnabled = await uiSettingsClient.get<boolean>( - ENABLE_ASSET_CRITICALITY_SETTING - ); let isNewRiskScoreModuleInstalled = false; if (isNewRiskScoreModuleAvailable) { @@ -87,29 +79,27 @@ export const enrichEvents: EnrichEventsFunction = async ({ ); } - if (isAssetCriticalityEnabled) { - const assetCriticalityIndexExist = await isIndexExist({ - services, - index: getAssetCriticalityIndex(spaceId), - }); - if (assetCriticalityIndexExist) { - enrichments.push( - createUserAssetCriticalityEnrichments({ - services, - logger, - events, - spaceId, - }) - ); - enrichments.push( - createHostAssetCriticalityEnrichments({ - services, - logger, - events, - spaceId, - }) - ); - } + const assetCriticalityIndexExist = await isIndexExist({ + services, + index: getAssetCriticalityIndex(spaceId), + }); + if (assetCriticalityIndexExist) { + enrichments.push( + createUserAssetCriticalityEnrichments({ + services, + logger, + events, + spaceId, + }) + ); + enrichments.push( + createHostAssetCriticalityEnrichments({ + services, + logger, + events, + spaceId, + }) + ); } const allEnrichmentsResults = await Promise.allSettled(enrichments); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.mock.ts index 9de2d8c6bae2c..9822dfd1dad1f 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.mock.ts @@ -9,7 +9,6 @@ import type { AssetCriticalityService } from './asset_criticality_service'; const buildMockAssetCriticalityService = (): jest.Mocked<AssetCriticalityService> => ({ getCriticalitiesByIdentifiers: jest.fn().mockResolvedValue([]), - isEnabled: jest.fn().mockReturnValue(true), }); export const assetCriticalityServiceMock = { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.ts index b67efbfa58e01..e56454499a00e 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_service.ts @@ -7,7 +7,6 @@ import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import { isEmpty } from 'lodash/fp'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../common/constants'; import type { AssetCriticalityRecord } from '../../../../common/api/entity_analytics'; import type { AssetCriticalityDataClient } from './asset_criticality_data_client'; @@ -24,7 +23,6 @@ export interface AssetCriticalityService { getCriticalitiesByIdentifiers: ( identifiers: CriticalityIdentifier[] ) => Promise<AssetCriticalityRecord[]>; - isEnabled: () => Promise<boolean>; } const isCriticalityIdentifierValid = (identifier: CriticalityIdentifier): boolean => @@ -94,9 +92,7 @@ interface AssetCriticalityServiceFactoryOptions { export const assetCriticalityServiceFactory = ({ assetCriticalityDataClient, - uiSettingsClient, }: AssetCriticalityServiceFactoryOptions): AssetCriticalityService => ({ getCriticalitiesByIdentifiers: (identifiers: CriticalityIdentifier[]) => getCriticalitiesByIdentifiers({ assetCriticalityDataClient, identifiers }), - isEnabled: () => uiSettingsClient.get<boolean>(ENABLE_ASSET_CRITICALITY_SETTING), }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts index 960f6c87be283..93251bcf92652 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/bulk_upload.ts @@ -17,11 +17,9 @@ import type { ConfigType } from '../../../../config'; import { ASSET_CRITICALITY_PUBLIC_BULK_UPLOAD_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -72,7 +70,6 @@ export const assetCriticalityPublicBulkUploadRoute = ( const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const assetCriticalityClient = securitySolution.getAssetCriticalityDataClient(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts index 4e0692f631718..6c2437081500d 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts @@ -13,11 +13,9 @@ import { DeleteAssetCriticalityRecordRequestQuery } from '../../../../../common/ import { ASSET_CRITICALITY_PUBLIC_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -62,7 +60,6 @@ export const assetCriticalityPublicDeleteRoute = ( const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const assetCriticalityClient = securitySolution.getAssetCriticalityDataClient(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts index ed63f6207fec1..048df61757a56 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/get.ts @@ -15,11 +15,9 @@ import { import { ASSET_CRITICALITY_PUBLIC_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -52,7 +50,6 @@ export const assetCriticalityPublicGetRoute = ( ): Promise<IKibanaResponse<GetAssetCriticalityRecordResponse>> => { const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const securitySolution = await context.securitySolution; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts index 64bbca127ed77..a6316646bc612 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/list.ts @@ -11,13 +11,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { ASSET_CRITICALITY_PUBLIC_LIST_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; import type { FindAssetCriticalityRecordsResponse } from '../../../../../common/api/entity_analytics/asset_criticality'; import { FindAssetCriticalityRecordsRequestQuery } from '../../../../../common/api/entity_analytics/asset_criticality'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -50,7 +48,6 @@ export const assetCriticalityPublicListRoute = ( ): Promise<IKibanaResponse<FindAssetCriticalityRecordsResponse>> => { const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const securitySolution = await context.securitySolution; const assetCriticalityClient = securitySolution.getAssetCriticalityDataClient(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts index 7f6b80dd92909..8c40335423973 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/privileges.ts @@ -11,12 +11,10 @@ import type { AssetCriticalityGetPrivilegesResponse } from '../../../../../commo import { ASSET_CRITICALITY_INTERNAL_PRIVILEGES_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; import { getUserAssetCriticalityPrivileges } from '../get_user_asset_criticality_privileges'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -46,8 +44,6 @@ export const assetCriticalityInternalPrivilegesRoute = ( ): Promise<IKibanaResponse<AssetCriticalityGetPrivilegesResponse>> => { const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); - await checkAndInitAssetCriticalityResources(context, logger); const [_, { security }] = await getStartServices(); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts index a0070503a3f8c..fc1cc92bbe1cf 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/status.ts @@ -11,12 +11,10 @@ import type { GetAssetCriticalityStatusResponse } from '../../../../../common/ap import { ASSET_CRITICALITY_INTERNAL_STATUS_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; import type { EntityAnalyticsRoutesDeps } from '../../types'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import { AssetCriticalityAuditActions } from '../audit'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; @@ -41,7 +39,6 @@ export const assetCriticalityInternalStatusRoute = ( ): Promise<IKibanaResponse<GetAssetCriticalityStatusResponse>> => { const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const securitySolution = await context.securitySolution; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts index cbe434ccb25cf..8c1d94176111c 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upload_csv.ts @@ -16,13 +16,11 @@ import type { HapiReadableStream } from '../../../../types'; import { ASSET_CRITICALITY_PUBLIC_CSV_UPLOAD_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; import { transformCSVToUpsertRecords } from '../transform_csv_to_upsert_records'; import { createAssetCriticalityProcessedFileEvent } from '../../../telemetry/event_based/events'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; @@ -82,7 +80,6 @@ export const assetCriticalityPublicCSVUploadRoute = ( const telemetry = coreStart.analytics; try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const assetCriticalityClient = securitySolution.getAssetCriticalityDataClient(); const fileStream = request.body.file as HapiReadableStream; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts index cab348e7c5518..488a75c0196ab 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts @@ -16,14 +16,12 @@ import { import { ASSET_CRITICALITY_PUBLIC_URL, APP_ID, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, } from '../../../../../common/constants'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { AssetCriticalityAuditActions } from '../audit'; import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; -import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; export const assetCriticalityPublicUpsertRoute = ( router: EntityAnalyticsRoutesDeps['router'], @@ -53,7 +51,6 @@ export const assetCriticalityPublicUpsertRoute = ( ): Promise<IKibanaResponse<CreateAssetCriticalityRecordResponse>> => { const siemResponse = buildSiemResponse(response); try { - await assertAdvancedSettingsEnabled(await context.core, ENABLE_ASSET_CRITICALITY_SETTING); await checkAndInitAssetCriticalityResources(context, logger); const securitySolution = await context.securitySolution; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts index 45ad1241fda33..ff1062393c935 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_risk_scores.ts @@ -175,13 +175,6 @@ const processScores = async ({ return []; } - const isAssetCriticalityEnabled = await assetCriticalityService.isEnabled(); - if (!isAssetCriticalityEnabled) { - return buckets.map((bucket) => - formatForResponse({ bucket, now, identifierField, includeNewFields: false }) - ); - } - const identifiers = buckets.map((bucket) => ({ id_field: identifierField, id_value: bucket.key[identifierField], diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index ecf3629b54831..842b8bbeceff8 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -40,7 +40,6 @@ import { DEFAULT_ALERT_TAGS_VALUE, EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER, EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION, - ENABLE_ASSET_CRITICALITY_SETTING, ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING, } from '../common/constants'; import type { ExperimentalFeatures } from '../common/experimental_features'; @@ -180,24 +179,6 @@ export const initUiSettings = ( requiresPageReload: true, schema: schema.boolean(), }, - [ENABLE_ASSET_CRITICALITY_SETTING]: { - name: i18n.translate('xpack.securitySolution.uiSettings.enableAssetCriticalityTitle', { - defaultMessage: 'Asset Criticality', - }), - value: false, - description: i18n.translate( - 'xpack.securitySolution.uiSettings.enableAssetCriticalityDescription', - { - defaultMessage: - '<p>Enables asset criticality assignment workflows and its contributions to entity risk </p>', - values: { p: (chunks) => `<p>${chunks}</p>` }, - } - ), - type: 'boolean', - category: [APP_ID], - requiresPageReload: true, - schema: schema.boolean(), - }, [EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER]: { name: i18n.translate( 'xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzer', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 281807b6db45f..d2c35721fdddb 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -40594,8 +40594,6 @@ "xpack.securitySolution.uiSettings.defaultThreatIndexLabel": "Index de menaces", "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "<p>Période de temps par défaut dans le filtre de temps Security.</p>", "xpack.securitySolution.uiSettings.defaultTimeRangeLabel": "Période du filtre de temps", - "xpack.securitySolution.uiSettings.enableAssetCriticalityDescription": "<p>Permet des flux de travail pour l'affectation de l'état critique des actifs et ses contributions au risque de l'entité </p>", - "xpack.securitySolution.uiSettings.enableAssetCriticalityTitle": "Criticité des ressources", "xpack.securitySolution.uiSettings.enableCcsReadWarningLabel": "Avertissement lié aux privilèges de la règle CCS", "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "<p>Active les avertissements de vérification des privilèges dans les règles relatives aux index CCS</p>", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "<p>Active le fil d'actualités</p>", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 19d3dfb274fa2..bc4f0b3f6cf1a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -40340,8 +40340,6 @@ "xpack.securitySolution.uiSettings.defaultThreatIndexLabel": "脅威インデックス", "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "<p>セキュリティ時間フィルダーのデフォルトの期間です。</p>", "xpack.securitySolution.uiSettings.defaultTimeRangeLabel": "時間フィルターの期間", - "xpack.securitySolution.uiSettings.enableAssetCriticalityDescription": "<p>アセット重要度割り当てワークフローとエンティティリスクへの寄与を有効化します </p>", - "xpack.securitySolution.uiSettings.enableAssetCriticalityTitle": "アセット重要度", "xpack.securitySolution.uiSettings.enableCcsReadWarningLabel": "CCSルール権限警告", "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "<p>CCSインデックスのルールで権限チェック警告を有効にします</p>", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "<p>ニュースフィードを有効にします</p>", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d9e89fe098903..e018909babf64 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -40385,8 +40385,6 @@ "xpack.securitySolution.uiSettings.defaultThreatIndexLabel": "威胁索引", "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "<p>Security 时间筛选中的默认时段。</p>", "xpack.securitySolution.uiSettings.defaultTimeRangeLabel": "时间筛选时段", - "xpack.securitySolution.uiSettings.enableAssetCriticalityDescription": "<p>启用资产关键度分配工作流及其对实体风险的贡献率 </p>", - "xpack.securitySolution.uiSettings.enableAssetCriticalityTitle": "资产关键度", "xpack.securitySolution.uiSettings.enableCcsReadWarningLabel": "CCS 规则权限警告", "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "<p>在规则中为 CCS 索引启用权限检查警告</p>", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "<p>启用新闻源</p>", diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/custom_query.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/custom_query.ts index 8c1462a84a971..5828a29eccb54 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/custom_query.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/custom_query.ts @@ -45,7 +45,6 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL, - ENABLE_ASSET_CRITICALITY_SETTING, } from '@kbn/security-solution-plugin/common/constants'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { deleteAllExceptions } from '../../../../../lists_and_exception_lists/utils'; @@ -95,7 +94,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const esDeleteAllIndices = getService('esDeleteAllIndices'); // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); @@ -334,9 +332,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts index e2d39bce4b024..9515924213ce6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts @@ -35,10 +35,7 @@ import { ALERT_GROUP_ID, } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { - DETECTION_ENGINE_RULES_URL, - ENABLE_ASSET_CRITICALITY_SETTING, -} from '@kbn/security-solution-plugin/common/constants'; +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { getEqlRuleForAlertTesting, getAlerts, @@ -72,7 +69,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); // TODO: add a new service for loading archiver files similar to "getService('es')" @@ -774,9 +770,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql_alert_suppression.ts index 26764650287fc..0c3069b3c3b62 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql_alert_suppression.ts @@ -19,10 +19,7 @@ import { TIMESTAMP, ALERT_START, } from '@kbn/rule-data-utils'; -import { - DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL, - ENABLE_ASSET_CRITICALITY_SETTING, -} from '@kbn/security-solution-plugin/common/constants'; +import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; import { getSuppressionMaxSignalsWarning as getSuppressionMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; @@ -1702,14 +1699,9 @@ export default ({ getService }: FtrProviderContext) => { }); describe('alert enrichment', () => { - const kibanaServer = getService('kibanaServer'); - before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/entity/risks'); await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts index d44896115fae3..723a2a7d2dfa3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts @@ -14,7 +14,6 @@ import { getCreateEsqlRulesSchemaMock } from '@kbn/security-solution-plugin/comm import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { getPreviewAlerts, previewRule, @@ -40,7 +39,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const utils = getService('securitySolutionUtils'); const { indexEnhancedDocuments, indexListOfDocuments, indexGeneratedDocuments } = @@ -916,9 +914,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql_suppression.ts index 2d4618a431599..24685cc137f0e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql_suppression.ts @@ -25,7 +25,6 @@ import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_ import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; import { getSuppressionMaxSignalsWarning as getSuppressionMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { getPreviewAlerts, previewRule, @@ -48,7 +47,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const { indexEnhancedDocuments, indexListOfDocuments, indexGeneratedDocuments } = dataGeneratorFactory({ es, @@ -2070,9 +2068,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match.ts index 663da2aef5784..5b7f79615d635 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match.ts @@ -41,7 +41,6 @@ import { } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { previewRule, getAlerts, @@ -186,7 +185,6 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); @@ -1655,9 +1653,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match_alert_suppression.ts index a6ac2fa6b139e..1ecf949b18951 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/indicator_match_alert_suppression.ts @@ -21,7 +21,6 @@ import { import { getSuppressionMaxSignalsWarning as getSuppressionMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { ThreatMatchRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; @@ -44,7 +43,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const { indexListOfDocuments: indexListOfSourceDocuments, @@ -2568,9 +2566,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts index 1418d6953177e..2d63847ca0db7 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning.ts @@ -29,10 +29,7 @@ import { } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { expect } from 'expect'; -import { - DETECTION_ENGINE_RULES_URL, - ENABLE_ASSET_CRITICALITY_SETTING, -} from '@kbn/security-solution-plugin/common/constants'; +import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; import { createListsIndex, deleteAllExceptions, @@ -63,7 +60,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const request = supertestLib(url.format(config.get('servers.kibana'))); @@ -331,9 +327,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning_alert_suppression.ts index 39a7138451f34..8ebcafcdc46b5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/machine_learning_alert_suppression.ts @@ -22,10 +22,7 @@ import { TIMESTAMP, } from '@kbn/rule-data-utils'; import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; -import { - DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL, - ENABLE_ASSET_CRITICALITY_SETTING, -} from '@kbn/security-solution-plugin/common/constants'; +import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; import { EsArchivePathBuilder } from '../../../../../../es_archive_path_builder'; import { FtrProviderContext } from '../../../../../../ftr_provider_context'; import { @@ -1102,14 +1099,9 @@ export default ({ getService }: FtrProviderContext) => { }); describe('with enrichments', () => { - const kibanaServer = getService('kibanaServer'); - before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/entity/risks'); await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms.ts index a1695ec04021c..970d6ab3ba6ed 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms.ts @@ -14,7 +14,7 @@ import { orderBy } from 'lodash'; import { getCreateNewTermsRulesSchemaMock } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/mocks'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; + import { getAlerts, getPreviewAlerts, @@ -43,7 +43,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const { indexEnhancedDocuments } = dataGeneratorFactory({ es, index: 'new_terms', @@ -1067,9 +1066,6 @@ export default ({ getService }: FtrProviderContext) => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms_alert_suppression.ts index 285bb81c6ac93..41d88869cdf45 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/new_terms_alert_suppression.ts @@ -18,7 +18,6 @@ import { TIMESTAMP, ALERT_START, } from '@kbn/rule-data-utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { getSuppressionMaxSignalsWarning as getSuppressionMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { getCreateNewTermsRulesSchemaMock } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/mocks'; import { NewTermsRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; @@ -2250,15 +2249,11 @@ export default ({ getService }: FtrProviderContext) => { const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); const path = dataPathBuilder.getPath('auditbeat/hosts'); - const kibanaServer = getService('kibanaServer'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/entity/risks'); await esArchiver.load(path); await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold.ts index e0e93ba8ed300..2f7086664fbcb 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold.ts @@ -27,7 +27,6 @@ import { ALERT_THRESHOLD_RESULT, } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { createRule, deleteAllRules, @@ -51,7 +50,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); @@ -447,9 +445,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts index 52cf49b711394..ecc97d8615f3f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threshold_alert_suppression.ts @@ -21,7 +21,6 @@ import { DETECTION_ENGINE_SIGNALS_STATUS_URL as DETECTION_ENGINE_ALERTS_STATUS_U import { ThresholdRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { AlertSuppression } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema'; @@ -44,7 +43,6 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); @@ -994,9 +992,6 @@ export default ({ getService }: FtrProviderContext) => { describe('with asset criticality', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/asset_criticality'); - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); }); after(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality.ts index 976bfaa8f1113..bc5eccd168418 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality.ts @@ -19,8 +19,6 @@ import { assetCriticalityRouteHelpersFactory, getAssetCriticalityDoc, getAssetCriticalityIndex, - enableAssetCriticalityAdvancedSetting, - disableAssetCriticalityAdvancedSetting, createAssetCriticalityRecords, riskEngineRouteHelpersFactory, } from '../../utils'; @@ -28,7 +26,6 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext) => { const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const log = getService('log'); const supertest = getService('supertest'); const assetCriticalityRoutes = assetCriticalityRouteHelpersFactory(supertest); @@ -41,14 +38,6 @@ export default ({ getService }: FtrProviderContext) => { await cleanAssetCriticality({ log, es }); }); - after(async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - }); - - beforeEach(async () => { - await enableAssetCriticalityAdvancedSetting(kibanaServer, log); - }); - afterEach(async () => { await riskEngineRoutes.cleanUp(); await cleanAssetCriticality({ log, es }); @@ -181,20 +170,6 @@ export default ({ getService }: FtrProviderContext) => { expectStatusCode: 400, }); }); - - it('should return 403 if the advanced setting is disabled', async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - - const validAssetCriticality = { - id_field: 'host.name', - id_value: 'host-01', - criticality_level: 'high_impact', - }; - - await assetCriticalityRoutes.upsert(validAssetCriticality, { - expectStatusCode: 403, - }); - }); }); describe('get', () => { @@ -220,14 +195,6 @@ export default ({ getService }: FtrProviderContext) => { expectStatusCode: 400, }); }); - - it('should return 403 if the advanced setting is disabled', async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - - await assetCriticalityRoutes.get('host.name', 'doesnt-matter', { - expectStatusCode: 403, - }); - }); }); describe('list', () => { @@ -424,20 +391,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should return a 403 if the advanced setting is disabled', async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - - const validRecord: CreateAssetCriticalityRecord = { - id_field: 'host.name', - id_value: 'delete-me', - criticality_level: 'high_impact', - }; - - await assetCriticalityRoutes.bulkUpload([validRecord], { - expectStatusCode: 403, - }); - }); - it('should correctly upload a valid record for one entity', async () => { const validRecord: CreateAssetCriticalityRecord = { id_field: 'host.name', @@ -533,14 +486,6 @@ export default ({ getService }: FtrProviderContext) => { expect(res.body.deleted).to.eql(false); expect(res.body.record).to.eql(undefined); }); - - it('should return 403 if the advanced setting is disabled', async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - - await assetCriticalityRoutes.delete('host.name', 'doesnt-matter', { - expectStatusCode: 403, - }); - }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_csv_upload.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_csv_upload.ts index 28a42d02bdaec..496cde9a79e13 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_csv_upload.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_csv_upload.ts @@ -8,8 +8,6 @@ import expect from 'expect'; import { assetCriticalityRouteHelpersFactory, cleanAssetCriticality, - disableAssetCriticalityAdvancedSetting, - enableAssetCriticalityAdvancedSetting, getAssetCriticalityDoc, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -18,7 +16,6 @@ export default ({ getService }: FtrProviderContext) => { const esClient = getService('es'); const supertest = getService('supertest'); const assetCriticalityRoutes = assetCriticalityRouteHelpersFactory(supertest); - const kibanaServer = getService('kibanaServer'); const log = getService('log'); const expectAssetCriticalityDocMatching = async (expectedDoc: { id_field: string; @@ -37,10 +34,6 @@ export default ({ getService }: FtrProviderContext) => { await cleanAssetCriticality({ es: esClient, namespace: 'default', log }); }); - beforeEach(async () => { - await enableAssetCriticalityAdvancedSetting(kibanaServer, log); - }); - after(async () => { await cleanAssetCriticality({ es: esClient, namespace: 'default', log }); }); @@ -188,13 +181,5 @@ export default ({ getService }: FtrProviderContext) => { failed: 0, }); }); - - it('should return 403 if the advanced setting is disabled', async () => { - await disableAssetCriticalityAdvancedSetting(kibanaServer, log); - - await assetCriticalityRoutes.uploadCsv('host,host-1,low_impact', { - expectStatusCode: 403, - }); - }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts index 0c187d3f45cc0..7b35787cafe24 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/asset_criticality_privileges.ts @@ -6,10 +6,7 @@ */ import expect from '@kbn/expect'; import { ROLES as SERVERLESS_USERNAMES } from '@kbn/security-solution-plugin/common/test'; -import { - assetCriticalityRouteHelpersFactoryNoAuth, - enableAssetCriticalityAdvancedSetting, -} from '../../utils'; +import { assetCriticalityRouteHelpersFactoryNoAuth } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; import { usersAndRolesFactory } from '../../utils/users_and_roles'; @@ -67,9 +64,6 @@ const USERNAME_TO_ROLES = { }; export default ({ getService }: FtrProviderContext) => { - const kibanaServer = getService('kibanaServer'); - const log = getService('log'); - describe('Entity Analytics - Asset Criticality Privileges API', () => { describe('@ess Asset Criticality Privileges API', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); @@ -95,7 +89,6 @@ export default ({ getService }: FtrProviderContext) => { }); before(async () => { await createPrivilegeTestUsers(); - await enableAssetCriticalityAdvancedSetting(kibanaServer, log); }); describe('Asset Criticality privileges API', () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts index 76baaec707db0..fb50a9beeed90 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_entity_calculation.ts @@ -23,7 +23,6 @@ import { cleanAssetCriticality, waitForAssetCriticalityToBePresent, riskEngineRouteHelpersFactory, - enableAssetCriticalityAdvancedSetting, sanitizeScores, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -34,7 +33,6 @@ export default ({ getService }: FtrProviderContext): void => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); @@ -77,9 +75,6 @@ export default ({ getService }: FtrProviderContext): void => { describe('@ess @serverless @serverlessQA Risk Scoring Entity Calculation API', function () { this.tags(['esGate']); - before(async () => { - await enableAssetCriticalityAdvancedSetting(kibanaServer, log); - }); context('with auditbeat data', () => { const { indexListOfDocuments } = dataGeneratorFactory({ diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts index fbe2ca5cfe210..af4567eac3d6d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_score_preview.ts @@ -23,7 +23,6 @@ import { cleanAssetCriticality, createAndSyncRuleAndAlertsFactory, deleteAllRiskScores, - enableAssetCriticalityAdvancedSetting, sanitizeScores, waitForAssetCriticalityToBePresent, } from '../../utils'; @@ -35,7 +34,6 @@ export default ({ getService }: FtrProviderContext): void => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - const kibanaServer = getService('kibanaServer'); const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log }); const previewRiskScores = async ({ @@ -70,10 +68,6 @@ export default ({ getService }: FtrProviderContext): void => { }; describe('@ess @serverless Risk Scoring Preview API', () => { - before(async () => { - await enableAssetCriticalityAdvancedSetting(kibanaServer, log); - }); - context('with auditbeat data', () => { const { indexListOfDocuments } = dataGeneratorFactory({ es, diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/asset_criticality.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/asset_criticality.ts index 4c541d48b436b..690e8f99b4611 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/asset_criticality.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/asset_criticality.ts @@ -16,7 +16,6 @@ import { ASSET_CRITICALITY_PUBLIC_LIST_URL, ASSET_CRITICALITY_INTERNAL_STATUS_URL, ASSET_CRITICALITY_INTERNAL_PRIVILEGES_URL, - ENABLE_ASSET_CRITICALITY_SETTING, API_VERSIONS, ASSET_CRITICALITY_PUBLIC_BULK_UPLOAD_URL, } from '@kbn/security-solution-plugin/common/constants'; @@ -28,51 +27,12 @@ import type { import type { Client } from '@elastic/elasticsearch'; import type { ToolingLog } from '@kbn/tooling-log'; import querystring from 'querystring'; -import { KbnClient } from '@kbn/test'; import { SupertestWithoutAuthProviderType } from '@kbn/ftr-common-functional-services'; import { routeWithNamespace, waitFor } from '../../../../common/utils/security_solution'; export const getAssetCriticalityIndex = (namespace?: string) => `.asset-criticality.asset-criticality-${namespace ?? 'default'}`; -export const enableAssetCriticalityAdvancedSetting = async ( - kibanaServer: KbnClient, - log: ToolingLog -) => { - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: true, - }); - - // and wait for the setting to be applied - await waitFor( - async () => { - const setting = await kibanaServer.uiSettings.get(ENABLE_ASSET_CRITICALITY_SETTING); - return setting === true; - }, - 'disableAssetCriticalityAdvancedSetting', - log - ); -}; - -export const disableAssetCriticalityAdvancedSetting = async ( - kibanaServer: KbnClient, - log: ToolingLog -) => { - await kibanaServer.uiSettings.update({ - [ENABLE_ASSET_CRITICALITY_SETTING]: false, - }); - - // and wait for the setting to be applied - await waitFor( - async () => { - const setting = await kibanaServer.uiSettings.get(ENABLE_ASSET_CRITICALITY_SETTING); - return setting === false; - }, - 'disableAssetCriticalityAdvancedSetting', - log - ); -}; - export const cleanAssetCriticality = async ({ log, es, diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts index 1a48a7835f195..016161b231a37 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/asset_criticality_upload_page.cy.ts @@ -12,7 +12,6 @@ import { RESULT_STEP, VALID_LINES_MESSAGE, } from '../../screens/asset_criticality'; -import { enableAssetCriticality } from '../../tasks/api_calls/kibana_advanced_settings'; import { clickAssignButton, uploadAssetCriticalityFile } from '../../tasks/asset_criticality'; import { login } from '../../tasks/login'; import { visit } from '../../tasks/navigation'; @@ -26,7 +25,6 @@ describe( () => { beforeEach(() => { login(); - enableAssetCriticality(); visit(ENTITY_ANALYTICS_ASSET_CRITICALITY_URL); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts index 976e68ba1bbc1..a65d7ddb6371a 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts @@ -42,7 +42,6 @@ import { ENTRA_DOCUMENT_TAB, OKTA_DOCUMENT_TAB, } from '../../screens/users/flyout_asset_panel'; -import { enableAssetCriticality } from '../../tasks/api_calls/kibana_advanced_settings'; const USER_NAME = 'user1'; const SIEM_KIBANA_HOST_NAME = 'Host-fwarau82er'; @@ -66,7 +65,6 @@ describe( cy.task('esArchiverLoad', { archiveName: 'risk_scores_new_complete_data' }); cy.task('esArchiverLoad', { archiveName: 'query_alert', useCreate: true, docsOnly: true }); cy.task('esArchiverLoad', { archiveName: 'user_managed_data' }); - enableAssetCriticality(); mockRiskEngineEnabled(); login(); visitWithTimeRange(ALERTS_URL); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/kibana_advanced_settings.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/kibana_advanced_settings.ts index 7307fa2418b68..09735e45ee4e4 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/kibana_advanced_settings.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/kibana_advanced_settings.ts @@ -6,7 +6,6 @@ */ import { SECURITY_SOLUTION_SHOW_RELATED_INTEGRATIONS_ID } from '@kbn/management-settings-ids'; -import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants'; import { rootRequest } from './common'; export const setKibanaSetting = (key: string, value: boolean | number | string) => { @@ -24,7 +23,3 @@ export const enableRelatedIntegrations = () => { export const disableRelatedIntegrations = () => { setKibanaSetting(SECURITY_SOLUTION_SHOW_RELATED_INTEGRATIONS_ID, false); }; - -export const enableAssetCriticality = () => { - setKibanaSetting(ENABLE_ASSET_CRITICALITY_SETTING, true); -}; From ad2ac714fc5c34e95f6eb133cc222b609a1f3a99 Mon Sep 17 00:00:00 2001 From: Jon <jon@elastic.co> Date: Tue, 15 Oct 2024 20:48:21 -0500 Subject: [PATCH 28/31] Reapply "[Security Solution] [Attack discovery] Output chunking / refinement, LangGraph migration, and evaluation improvements (#195669)" (#196440) #195669 + #196381 This reverts commit dbe6d82584c99fb8eda7fa117e220a97cfb0c33b. --------- Co-authored-by: Alex Szabo <alex.szabo@elastic.co> --- .../index.test.ts} | 2 +- .../index.ts} | 7 +- .../get_raw_data_or_default/index.test.ts | 28 + .../helpers/get_raw_data_or_default/index.ts | 13 + .../helpers/is_raw_data_valid/index.test.ts | 51 + .../alerts/helpers/is_raw_data_valid/index.ts | 11 + .../size_is_out_of_range/index.test.ts | 47 + .../helpers/size_is_out_of_range/index.ts | 12 + .../impl/alerts/helpers/types.ts | 14 + .../attack_discovery/common_attributes.gen.ts | 4 +- .../common_attributes.schema.yaml | 2 - .../evaluation/post_evaluate_route.gen.ts | 2 + .../post_evaluate_route.schema.yaml | 4 + .../kbn-elastic-assistant-common/index.ts | 16 + .../alerts_settings/alerts_settings.tsx | 3 +- .../alerts_settings_management.tsx | 1 + .../evaluation_settings.tsx | 64 +- .../evaluation_settings/translations.ts | 30 + .../impl/assistant_context/constants.tsx | 5 + .../impl/assistant_context/index.tsx | 5 +- .../impl/knowledge_base/alerts_range.tsx | 64 +- .../packages/kbn-elastic-assistant/index.ts | 20 + x-pack/plugins/elastic_assistant/README.md | 10 +- .../docs/img/default_assistant_graph.png | Bin 30104 -> 29798 bytes .../img/default_attack_discovery_graph.png | Bin 0 -> 22551 bytes .../scripts/draw_graph_script.ts | 46 +- .../__mocks__/attack_discovery_schema.mock.ts | 2 +- .../server/__mocks__/data_clients.mock.ts | 2 +- .../server/__mocks__/request_context.ts | 2 +- .../server/__mocks__/response.ts | 2 +- .../server/ai_assistant_service/index.ts | 4 +- .../evaluation/__mocks__/mock_examples.ts | 55 + .../evaluation/__mocks__/mock_runs.ts | 53 + .../attack_discovery/evaluation/constants.ts | 911 +++++++++++ .../evaluation/example_input/index.test.ts | 75 + .../evaluation/example_input/index.ts | 52 + .../get_default_prompt_template/index.test.ts | 42 + .../get_default_prompt_template/index.ts | 33 + .../index.test.ts | 125 ++ .../index.ts | 29 + .../index.test.ts | 117 ++ .../index.ts | 27 + .../get_custom_evaluator/index.test.ts | 98 ++ .../helpers/get_custom_evaluator/index.ts | 69 + .../index.test.ts | 79 + .../index.ts | 39 + .../helpers/get_evaluator_llm/index.test.ts | 161 ++ .../helpers/get_evaluator_llm/index.ts | 65 + .../get_graph_input_overrides/index.test.ts | 121 ++ .../get_graph_input_overrides/index.ts | 29 + .../lib/attack_discovery/evaluation/index.ts | 122 ++ .../evaluation/run_evaluations/index.ts | 113 ++ .../constants.ts | 21 + .../index.test.ts | 22 + .../get_generate_or_end_decision/index.ts | 9 + .../edges/generate_or_end/index.test.ts | 72 + .../edges/generate_or_end/index.ts | 38 + .../index.test.ts | 43 + .../index.ts | 28 + .../helpers/get_should_end/index.test.ts | 60 + .../helpers/get_should_end/index.ts | 16 + .../generate_or_refine_or_end/index.test.ts | 118 ++ .../edges/generate_or_refine_or_end/index.ts | 66 + .../edges/helpers/get_has_results/index.ts | 11 + .../helpers/get_has_zero_alerts/index.ts | 12 + .../get_refine_or_end_decision/index.ts | 25 + .../helpers/get_should_end/index.ts | 16 + .../edges/refine_or_end/index.ts | 61 + .../get_retrieve_or_generate/index.ts | 13 + .../index.ts | 36 + .../index.ts | 14 + .../helpers/get_max_retries_reached/index.ts | 14 + .../default_attack_discovery_graph/index.ts | 122 ++ .../mock/mock_anonymization_fields.ts | 0 ...en_and_acknowledged_alerts_qery_results.ts | 25 + ...n_and_acknowledged_alerts_query_results.ts | 1396 +++++++++++++++++ .../discard_previous_generations/index.ts | 30 + .../get_alerts_context_prompt/index.test.ts} | 17 +- .../get_alerts_context_prompt/index.ts | 22 + .../get_anonymized_alerts_from_state/index.ts | 11 + .../get_use_unrefined_results/index.ts | 27 + .../nodes/generate/index.ts | 154 ++ .../nodes/generate/schema/index.ts | 84 + .../index.ts | 20 + .../nodes/helpers/extract_json/index.test.ts | 67 + .../nodes/helpers/extract_json/index.ts | 17 + .../generations_are_repeating/index.test.tsx | 90 ++ .../generations_are_repeating/index.tsx | 25 + .../index.ts | 34 + .../nodes/helpers/get_combined/index.ts | 14 + .../index.ts | 43 + .../helpers/get_continue_prompt/index.ts | 15 + .../index.ts | 9 + .../helpers/get_output_parser/index.test.ts | 31 + .../nodes/helpers/get_output_parser/index.ts | 13 + .../helpers/parse_combined_or_throw/index.ts | 53 + .../helpers/response_is_hallucinated/index.ts | 9 + .../discard_previous_refinements/index.ts | 30 + .../get_combined_refine_prompt/index.ts | 48 + .../get_default_refine_prompt/index.ts | 11 + .../get_use_unrefined_results/index.ts | 17 + .../nodes/refine/index.ts | 166 ++ .../anonymized_alerts_retriever/index.ts | 74 + .../get_anonymized_alerts/index.test.ts} | 18 +- .../helpers/get_anonymized_alerts/index.ts} | 14 +- .../nodes/retriever/index.ts | 70 + .../state/index.ts | 86 + .../default_attack_discovery_graph/types.ts | 28 + .../create_attack_discovery.test.ts | 4 +- .../create_attack_discovery.ts | 4 +- .../field_maps_configuration.ts | 0 .../find_all_attack_discoveries.ts | 4 +- ...d_attack_discovery_by_connector_id.test.ts | 2 +- .../find_attack_discovery_by_connector_id.ts | 4 +- .../get_attack_discovery.test.ts | 2 +- .../get_attack_discovery.ts | 4 +- .../attack_discovery/persistence}/index.ts | 15 +- .../persistence/transforms}/transforms.ts | 2 +- .../attack_discovery/persistence}/types.ts | 4 +- .../update_attack_discovery.test.ts | 4 +- .../update_attack_discovery.ts | 6 +- .../server/lib/langchain/graphs/index.ts | 35 +- .../{ => get}/get_attack_discovery.test.ts | 25 +- .../{ => get}/get_attack_discovery.ts | 8 +- .../routes/attack_discovery/helpers.test.ts | 805 ---------- .../attack_discovery/helpers/helpers.test.ts | 273 ++++ .../attack_discovery/{ => helpers}/helpers.ts | 231 +-- .../cancel}/cancel_attack_discovery.test.ts | 24 +- .../cancel}/cancel_attack_discovery.ts | 10 +- .../post/helpers/handle_graph_error/index.tsx | 73 + .../invoke_attack_discovery_graph/index.tsx | 127 ++ .../helpers/request_is_valid/index.test.tsx | 87 + .../post/helpers/request_is_valid/index.tsx | 33 + .../throw_if_error_counts_exceeded/index.ts | 44 + .../translations.ts | 28 + .../{ => post}/post_attack_discovery.test.ts | 40 +- .../{ => post}/post_attack_discovery.ts | 80 +- .../evaluate/get_graphs_from_names/index.ts | 35 + .../server/routes/evaluate/post_evaluate.ts | 43 +- .../server/routes/evaluate/utils.ts | 2 +- .../elastic_assistant/server/routes/index.ts | 4 +- .../server/routes/register_routes.ts | 6 +- .../plugins/elastic_assistant/server/types.ts | 4 +- .../actionable_summary/index.tsx | 43 +- .../attack_discovery_panel/index.tsx | 11 +- .../attack_discovery_panel/title/index.tsx | 27 +- .../get_attack_discovery_markdown.ts | 2 +- .../attack_discovery/hooks/use_poll_api.tsx | 6 +- .../empty_prompt/animated_counter/index.tsx | 2 +- .../pages/empty_prompt/index.test.tsx | 72 +- .../pages/empty_prompt/index.tsx | 29 +- .../helpers/show_empty_states/index.ts | 36 + .../pages/empty_states/index.test.tsx | 33 +- .../pages/empty_states/index.tsx | 44 +- .../attack_discovery/pages/failure/index.tsx | 48 +- .../pages/failure/translations.ts | 13 +- .../attack_discovery/pages/generate/index.tsx | 36 + .../pages/header/index.test.tsx | 13 + .../attack_discovery/pages/header/index.tsx | 16 +- .../settings_modal/alerts_settings/index.tsx | 77 + .../header/settings_modal/footer/index.tsx | 57 + .../pages/header/settings_modal/index.tsx | 160 ++ .../settings_modal/is_tour_enabled/index.ts | 18 + .../header/settings_modal/translations.ts | 81 + .../attack_discovery/pages/helpers.test.ts | 4 + .../public/attack_discovery/pages/helpers.ts | 31 +- .../public/attack_discovery/pages/index.tsx | 104 +- .../pages/loading_callout/index.test.tsx | 3 +- .../pages/loading_callout/index.tsx | 13 +- .../get_loading_callout_alerts_count/index.ts | 24 + .../loading_messages/index.test.tsx | 4 +- .../loading_messages/index.tsx | 16 +- .../pages/no_alerts/index.test.tsx | 2 +- .../pages/no_alerts/index.tsx | 17 +- .../attack_discovery/pages/results/index.tsx | 112 ++ .../use_attack_discovery/helpers.test.ts | 25 +- .../use_attack_discovery/helpers.ts | 11 +- .../use_attack_discovery/index.test.tsx | 33 +- .../use_attack_discovery/index.tsx | 17 +- .../attack_discovery_tool.test.ts | 340 ---- .../attack_discovery/attack_discovery_tool.ts | 115 -- .../get_attack_discovery_prompt.ts | 20 - .../get_output_parser.test.ts | 31 - .../attack_discovery/get_output_parser.ts | 80 - .../server/assistant/tools/index.ts | 2 - .../helpers.test.ts | 117 -- .../open_and_acknowledged_alerts/helpers.ts | 22 - .../open_and_acknowledged_alerts_tool.test.ts | 3 +- .../open_and_acknowledged_alerts_tool.ts | 10 +- .../plugins/security_solution/tsconfig.json | 1 - 190 files changed, 8378 insertions(+), 2148 deletions(-) rename x-pack/{plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts => packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts} (96%) rename x-pack/{plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts => packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts} (87%) create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts create mode 100644 x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts create mode 100644 x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts rename x-pack/plugins/{security_solution/server/assistant/tools => elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph}/mock/mock_anonymization_fields.ts (100%) create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts rename x-pack/plugins/{security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts => elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts} (70%) create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts rename x-pack/plugins/{security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts => elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts} (90%) rename x-pack/plugins/{security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts => elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts} (77%) create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/create_attack_discovery}/create_attack_discovery.test.ts (94%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/create_attack_discovery}/create_attack_discovery.ts (95%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/field_maps_configuration}/field_maps_configuration.ts (100%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/find_all_attack_discoveries}/find_all_attack_discoveries.ts (92%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/find_attack_discovery_by_connector_id}/find_attack_discovery_by_connector_id.test.ts (95%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/find_attack_discovery_by_connector_id}/find_attack_discovery_by_connector_id.ts (93%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/get_attack_discovery}/get_attack_discovery.test.ts (95%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/get_attack_discovery}/get_attack_discovery.ts (93%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence}/index.ts (92%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/transforms}/transforms.ts (98%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence}/types.ts (93%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/update_attack_discovery}/update_attack_discovery.test.ts (97%) rename x-pack/plugins/elastic_assistant/server/{ai_assistant_data_clients/attack_discovery => lib/attack_discovery/persistence/update_attack_discovery}/update_attack_discovery.ts (95%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => get}/get_attack_discovery.test.ts (85%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => get}/get_attack_discovery.ts (92%) delete mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => helpers}/helpers.ts (55%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => post/cancel}/cancel_attack_discovery.test.ts (80%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => post/cancel}/cancel_attack_discovery.ts (91%) create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => post}/post_attack_discovery.test.ts (79%) rename x-pack/plugins/elastic_assistant/server/routes/attack_discovery/{ => post}/post_attack_discovery.ts (79%) create mode 100644 x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts delete mode 100644 x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts similarity index 96% rename from x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts rename to x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts index c8b52779d7b42..975896f381443 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; +import { getOpenAndAcknowledgedAlertsQuery } from '.'; describe('getOpenAndAcknowledgedAlertsQuery', () => { it('returns the expected query', () => { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts similarity index 87% rename from x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts rename to x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts index 4090e71baa371..6f6e196053ca6 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts @@ -5,8 +5,13 @@ * 2.0. */ -import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +/** + * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. + * + * The alerts are ordered by risk score, and then from the most recent to the oldest. + */ export const getOpenAndAcknowledgedAlertsQuery = ({ alertsIndexPattern, anonymizationFields, diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts new file mode 100644 index 0000000000000..899b156d21767 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRawDataOrDefault } from '.'; + +describe('getRawDataOrDefault', () => { + it('returns the raw data when it is valid', () => { + const rawData = { + field1: [1, 2, 3], + field2: ['a', 'b', 'c'], + }; + + expect(getRawDataOrDefault(rawData)).toEqual(rawData); + }); + + it('returns an empty object when the raw data is invalid', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(getRawDataOrDefault(rawData)).toEqual({}); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts new file mode 100644 index 0000000000000..edbe320c95305 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isRawDataValid } from '../is_raw_data_valid'; +import type { MaybeRawData } from '../types'; + +/** Returns the raw data if it valid, or a default if it's not */ +export const getRawDataOrDefault = (rawData: MaybeRawData): Record<string, unknown[]> => + isRawDataValid(rawData) ? rawData : {}; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts new file mode 100644 index 0000000000000..cc205250e84db --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isRawDataValid } from '.'; + +describe('isRawDataValid', () => { + it('returns true for valid raw data', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns true when a field array is empty', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + field3: [], // the Fields API may return an empty array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when a field does not have an array of values', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(isRawDataValid(rawData)).toBe(false); + }); + + it('returns true for empty raw data', () => { + const rawData = {}; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when raw data is an unexpected type', () => { + const rawData = 1234; + + // @ts-expect-error + expect(isRawDataValid(rawData)).toBe(false); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts new file mode 100644 index 0000000000000..1a9623b15ea98 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MaybeRawData } from '../types'; + +export const isRawDataValid = (rawData: MaybeRawData): rawData is Record<string, unknown[]> => + typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts new file mode 100644 index 0000000000000..b118a5c94b26e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sizeIsOutOfRange } from '.'; +import { MAX_SIZE, MIN_SIZE } from '../types'; + +describe('sizeIsOutOfRange', () => { + it('returns true when size is undefined', () => { + const size = undefined; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is less than MIN_SIZE', () => { + const size = MIN_SIZE - 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is greater than MAX_SIZE', () => { + const size = MAX_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns false when size is exactly MIN_SIZE', () => { + const size = MIN_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is exactly MAX_SIZE', () => { + const size = MAX_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is within the valid range', () => { + const size = MIN_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts new file mode 100644 index 0000000000000..b2a93b79cbb42 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MAX_SIZE, MIN_SIZE } from '../types'; + +/** Return true if the provided size is out of range */ +export const sizeIsOutOfRange = (size?: number): boolean => + size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts new file mode 100644 index 0000000000000..5c81c99ce5732 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +export const MIN_SIZE = 10; +export const MAX_SIZE = 10000; + +/** currently the same shape as "fields" property in the ES response */ +export type MaybeRawData = SearchResponse['fields'] | undefined; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts index 9599e8596e553..8ade6084fd7de 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts @@ -39,7 +39,7 @@ export const AttackDiscovery = z.object({ /** * A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax */ - entitySummaryMarkdown: z.string(), + entitySummaryMarkdown: z.string().optional(), /** * An array of MITRE ATT&CK tactic for the attack discovery */ @@ -55,7 +55,7 @@ export const AttackDiscovery = z.object({ /** * The time the attack discovery was generated */ - timestamp: NonEmptyString, + timestamp: NonEmptyString.optional(), }); /** diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml index dcb72147f9408..3adf2f7836804 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml @@ -12,9 +12,7 @@ components: required: - 'alertIds' - 'detailsMarkdown' - - 'entitySummaryMarkdown' - 'summaryMarkdown' - - 'timestamp' - 'title' properties: alertIds: diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts index b6d51b9bea3fc..a0cbc22282c7b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts @@ -22,10 +22,12 @@ export type PostEvaluateBody = z.infer<typeof PostEvaluateBody>; export const PostEvaluateBody = z.object({ graphs: z.array(z.string()), datasetName: z.string(), + evaluatorConnectorId: z.string().optional(), connectorIds: z.array(z.string()), runName: z.string().optional(), alertsIndexPattern: z.string().optional().default('.alerts-security.alerts-default'), langSmithApiKey: z.string().optional(), + langSmithProject: z.string().optional(), replacements: Replacements.optional().default({}), size: z.number().optional().default(20), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml index d0bec37344165..071d80156890b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml @@ -61,6 +61,8 @@ components: type: string datasetName: type: string + evaluatorConnectorId: + type: string connectorIds: type: array items: @@ -72,6 +74,8 @@ components: default: ".alerts-security.alerts-default" langSmithApiKey: type: string + langSmithProject: + type: string replacements: $ref: "../conversations/common_attributes.schema.yaml#/components/schemas/Replacements" default: {} diff --git a/x-pack/packages/kbn-elastic-assistant-common/index.ts b/x-pack/packages/kbn-elastic-assistant-common/index.ts index d8b4858d3ba8b..41ed86dacd9db 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/index.ts @@ -25,3 +25,19 @@ export { export { transformRawData } from './impl/data_anonymization/transform_raw_data'; export { parseBedrockBuffer, handleBedrockChunk } from './impl/utils/bedrock'; export * from './constants'; + +/** currently the same shape as "fields" property in the ES response */ +export { type MaybeRawData } from './impl/alerts/helpers/types'; + +/** + * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. + * + * The alerts are ordered by risk score, and then from the most recent to the oldest. + */ +export { getOpenAndAcknowledgedAlertsQuery } from './impl/alerts/get_open_and_acknowledged_alerts_query'; + +/** Returns the raw data if it valid, or a default if it's not */ +export { getRawDataOrDefault } from './impl/alerts/helpers/get_raw_data_or_default'; + +/** Return true if the provided size is out of range */ +export { sizeIsOutOfRange } from './impl/alerts/helpers/size_is_out_of_range'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx index 60078178a1771..3b48c8d0861c5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx @@ -16,7 +16,7 @@ import * as i18n from '../../../knowledge_base/translations'; export const MIN_LATEST_ALERTS = 10; export const MAX_LATEST_ALERTS = 100; export const TICK_INTERVAL = 10; -export const RANGE_CONTAINER_WIDTH = 300; // px +export const RANGE_CONTAINER_WIDTH = 600; // px const LABEL_WRAPPER_MIN_WIDTH = 95; // px interface Props { @@ -52,6 +52,7 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting <AlertsRange knowledgeBase={knowledgeBase} setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} + value={knowledgeBase.latestAlerts} /> <EuiSpacer size="s" /> </EuiFlexItem> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx index 1a6f826bd415f..7a3998879078d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx @@ -40,6 +40,7 @@ export const AlertsSettingsManagement: React.FC<Props> = React.memo( knowledgeBase={knowledgeBase} setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} compressed={false} + value={knowledgeBase.latestAlerts} /> </EuiPanel> ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx index cefc008eba992..ffbcad48d1cac 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx @@ -17,28 +17,34 @@ import { EuiComboBox, EuiButton, EuiComboBoxOptionOption, + EuiComboBoxSingleSelectionShape, EuiTextColor, EuiFieldText, + EuiFieldNumber, EuiFlexItem, EuiFlexGroup, EuiLink, EuiPanel, } from '@elastic/eui'; - import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { GetEvaluateResponse, PostEvaluateRequestBodyInput, } from '@kbn/elastic-assistant-common'; +import { isEmpty } from 'lodash/fp'; + import * as i18n from './translations'; import { useAssistantContext } from '../../../assistant_context'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '../../../assistant_context/constants'; import { useLoadConnectors } from '../../../connectorland/use_load_connectors'; import { getActionTypeTitle, getGenAiConfig } from '../../../connectorland/helpers'; import { PRECONFIGURED_CONNECTOR } from '../../../connectorland/translations'; import { usePerformEvaluation } from '../../api/evaluate/use_perform_evaluation'; import { useEvaluationData } from '../../api/evaluate/use_evaluation_data'; +const AS_PLAIN_TEXT: EuiComboBoxSingleSelectionShape = { asPlainText: true }; + /** * Evaluation Settings -- development-only feature for evaluating models */ @@ -121,6 +127,18 @@ export const EvaluationSettings: React.FC = React.memo(() => { }, [setSelectedModelOptions] ); + + const [selectedEvaluatorModel, setSelectedEvaluatorModel] = useState< + Array<EuiComboBoxOptionOption<string>> + >([]); + + const onSelectedEvaluatorModelChange = useCallback( + (selected: Array<EuiComboBoxOptionOption<string>>) => setSelectedEvaluatorModel(selected), + [] + ); + + const [size, setSize] = useState<string>(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); + const visColorsBehindText = euiPaletteComplementary(connectors?.length ?? 0); const modelOptions = useMemo(() => { return ( @@ -170,19 +188,40 @@ export const EvaluationSettings: React.FC = React.memo(() => { // Perform Evaluation Button const handlePerformEvaluation = useCallback(async () => { + const evaluatorConnectorId = + selectedEvaluatorModel[0]?.key != null + ? { evaluatorConnectorId: selectedEvaluatorModel[0].key } + : {}; + + const langSmithApiKey = isEmpty(traceOptions.langSmithApiKey) + ? undefined + : traceOptions.langSmithApiKey; + + const langSmithProject = isEmpty(traceOptions.langSmithProject) + ? undefined + : traceOptions.langSmithProject; + const evalParams: PostEvaluateRequestBodyInput = { connectorIds: selectedModelOptions.flatMap((option) => option.key ?? []).sort(), graphs: selectedGraphOptions.map((option) => option.label).sort(), datasetName: selectedDatasetOptions[0]?.label, + ...evaluatorConnectorId, + langSmithApiKey, + langSmithProject, runName, + size: Number(size), }; performEvaluation(evalParams); }, [ performEvaluation, runName, selectedDatasetOptions, + selectedEvaluatorModel, selectedGraphOptions, selectedModelOptions, + size, + traceOptions.langSmithApiKey, + traceOptions.langSmithProject, ]); const getSection = (title: string, description: string) => ( @@ -355,6 +394,29 @@ export const EvaluationSettings: React.FC = React.memo(() => { onChange={onGraphOptionsChange} /> </EuiFormRow> + + <EuiFormRow + display="rowCompressed" + helpText={i18n.EVALUATOR_MODEL_DESCRIPTION} + label={i18n.EVALUATOR_MODEL} + > + <EuiComboBox + aria-label={i18n.EVALUATOR_MODEL} + compressed + onChange={onSelectedEvaluatorModelChange} + options={modelOptions} + selectedOptions={selectedEvaluatorModel} + singleSelection={AS_PLAIN_TEXT} + /> + </EuiFormRow> + + <EuiFormRow + display="rowCompressed" + helpText={i18n.DEFAULT_MAX_ALERTS_DESCRIPTION} + label={i18n.DEFAULT_MAX_ALERTS} + > + <EuiFieldNumber onChange={(e) => setSize(e.target.value)} value={size} /> + </EuiFormRow> </EuiAccordion> <EuiHorizontalRule margin={'s'} /> <EuiFlexGroup alignItems="center"> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts index 62902d0f14095..26eddb8a223c7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts @@ -78,6 +78,36 @@ export const CONNECTORS_LABEL = i18n.translate( } ); +export const EVALUATOR_MODEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelLabel', + { + defaultMessage: 'Evaluator model (optional)', + } +); + +export const DEFAULT_MAX_ALERTS = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsLabel', + { + defaultMessage: 'Default max alerts', + } +); + +export const EVALUATOR_MODEL_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelDescription', + { + defaultMessage: + 'Judge the quality of all predictions using a single model. (Default: use the same model as the connector)', + } +); + +export const DEFAULT_MAX_ALERTS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsDescription', + { + defaultMessage: + 'The default maximum number of alerts to send as context, which may be overridden by the Example input', + } +); + export const CONNECTORS_DESCRIPTION = i18n.translate( 'xpack.elasticAssistant.assistant.settings.evaluationSettings.connectorsDescription', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx index be7724d882278..92a2a3df2683b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -10,7 +10,9 @@ import { KnowledgeBaseConfig } from '../assistant/types'; export const ATTACK_DISCOVERY_STORAGE_KEY = 'attackDiscovery'; export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId'; +export const MAX_ALERTS_LOCAL_STORAGE_KEY = 'maxAlerts'; export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase'; +export const SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY = 'showSettingsTour'; export const STREAMING_LOCAL_STORAGE_KEY = 'streaming'; export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions'; export const CONVERSATION_TABLE_SESSION_STORAGE_KEY = 'conversationTable'; @@ -21,6 +23,9 @@ export const ANONYMIZATION_TABLE_SESSION_STORAGE_KEY = 'anonymizationTable'; /** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */ export const DEFAULT_LATEST_ALERTS = 20; +/** The default maximum number of alerts to be sent as context when generating Attack discoveries */ +export const DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS = 200; + export const DEFAULT_KNOWLEDGE_BASE_SETTINGS: KnowledgeBaseConfig = { latestAlerts: DEFAULT_LATEST_ALERTS, }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index c7b15f681a717..2319bf67de89a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -262,7 +262,10 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ docLinks, getComments, http, - knowledgeBase: { ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, ...localStorageKnowledgeBase }, + knowledgeBase: { + ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, + ...localStorageKnowledgeBase, + }, promptContexts, navigateToApp, nameSpace, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx index 63bd86121dcc1..6cfa60eff282d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -7,7 +7,7 @@ import { EuiRange, useGeneratedHtmlId } from '@elastic/eui'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useCallback } from 'react'; import { MAX_LATEST_ALERTS, MIN_LATEST_ALERTS, @@ -16,35 +16,57 @@ import { import { KnowledgeBaseConfig } from '../assistant/types'; import { ALERTS_RANGE } from './translations'; +export type SingleRangeChangeEvent = + | React.ChangeEvent<HTMLInputElement> + | React.KeyboardEvent<HTMLInputElement> + | React.MouseEvent<HTMLButtonElement>; + interface Props { - knowledgeBase: KnowledgeBaseConfig; - setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>; compressed?: boolean; + maxAlerts?: number; + minAlerts?: number; + onChange?: (e: SingleRangeChangeEvent) => void; + knowledgeBase?: KnowledgeBaseConfig; + setUpdatedKnowledgeBaseSettings?: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>; + step?: number; + value: string | number; } const MAX_ALERTS_RANGE_WIDTH = 649; // px export const AlertsRange: React.FC<Props> = React.memo( - ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, compressed = true }) => { + ({ + compressed = true, + knowledgeBase, + maxAlerts = MAX_LATEST_ALERTS, + minAlerts = MIN_LATEST_ALERTS, + onChange, + setUpdatedKnowledgeBaseSettings, + step = TICK_INTERVAL, + value, + }) => { const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' }); - return ( - <EuiRange - aria-label={ALERTS_RANGE} - compressed={compressed} - data-test-subj="alertsRange" - id={inputRangeSliderId} - max={MAX_LATEST_ALERTS} - min={MIN_LATEST_ALERTS} - onChange={(e) => + const handleOnChange = useCallback( + (e: SingleRangeChangeEvent) => { + if (knowledgeBase != null && setUpdatedKnowledgeBaseSettings != null) { setUpdatedKnowledgeBaseSettings({ ...knowledgeBase, latestAlerts: Number(e.currentTarget.value), - }) + }); } - showTicks - step={TICK_INTERVAL} - value={knowledgeBase.latestAlerts} + + if (onChange != null) { + onChange(e); + } + }, + [knowledgeBase, onChange, setUpdatedKnowledgeBaseSettings] + ); + + return ( + <EuiRange + aria-label={ALERTS_RANGE} + compressed={compressed} css={css` max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px; & .euiRangeTrack { @@ -52,6 +74,14 @@ export const AlertsRange: React.FC<Props> = React.memo( margin-inline-end: 0; } `} + data-test-subj="alertsRange" + id={inputRangeSliderId} + max={maxAlerts} + min={minAlerts} + onChange={handleOnChange} + showTicks + step={step} + value={value} /> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index 0baff57648cc8..7ec65c9601268 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -77,10 +77,17 @@ export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_ava export { ConnectorSelectorInline } from './impl/connectorland/connector_selector_inline/connector_selector_inline'; export { + /** The Attack discovery local storage key */ ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, + /** The default maximum number of alerts to be sent as context when generating Attack discoveries */ + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, DEFAULT_LATEST_ALERTS, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, + /** The local storage key that specifies the maximum number of alerts to send as context */ + MAX_ALERTS_LOCAL_STORAGE_KEY, + /** The local storage key that specifies whether the settings tour should be shown */ + SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, } from './impl/assistant_context/constants'; export { useLoadConnectors } from './impl/connectorland/use_load_connectors'; @@ -140,3 +147,16 @@ export { mergeBaseWithPersistedConversations } from './impl/assistant/helpers'; export { UpgradeButtons } from './impl/upgrade/upgrade_buttons'; export { getUserConversations, getPrompts, bulkUpdatePrompts } from './impl/assistant/api'; + +export { + /** A range slider component, typically used to configure the number of alerts sent as context */ + AlertsRange, + /** This event occurs when the `AlertsRange` slider is changed */ + type SingleRangeChangeEvent, +} from './impl/knowledge_base/alerts_range'; +export { + /** A label instructing the user to send fewer alerts */ + SELECT_FEWER_ALERTS, + /** Your anonymization settings will apply to these alerts (label) */ + YOUR_ANONYMIZATION_SETTINGS, +} from './impl/knowledge_base/translations'; diff --git a/x-pack/plugins/elastic_assistant/README.md b/x-pack/plugins/elastic_assistant/README.md index 2a1e47c177591..8cf2c0b8903dd 100755 --- a/x-pack/plugins/elastic_assistant/README.md +++ b/x-pack/plugins/elastic_assistant/README.md @@ -10,15 +10,21 @@ Maintained by the Security Solution team ## Graph structure +### Default Assistant graph + ![DefaultAssistantGraph](./docs/img/default_assistant_graph.png) +### Default Attack discovery graph + +![DefaultAttackDiscoveryGraph](./docs/img/default_attack_discovery_graph.png) + ## Development ### Generate graph structure To generate the graph structure, run `yarn draw-graph` from the plugin directory. -The graph will be generated in the `docs/img` directory of the plugin. +The graphs will be generated in the `docs/img` directory of the plugin. ### Testing -To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. \ No newline at end of file +To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png index e4ef8382317e5f827778d1bb34984644f87bbf9d..159b69c6d95723f08843347b2bb14d75ec2f3905 100644 GIT binary patch literal 29798 zcmcG$1zcOr@;4p|MT(W;E}>A071uy1#S4_uBEbnBJh-;BKq>C-))oz}1&XA&Yj7{_ zE^qqW+vm#f-rv3N=l%arl9S2q?3~%zb9QIHGjKa`I}f<8D61d~Ktlrn&`>YH?IPNQ zg0!^3%U7zh3NK{-Qt<<TIxwC905-Nxj<4jNF=%On8L;O6(&AU05d>!c>-*mvD7hzN zzoY{I!<_$y=YK24F)@WfPz-yh53?ic;wWWFP&A48U+71_Xyd=o;=gEDCwnIp&&yx5 z<Lg(_DB1)?KQjM2+W7Bih`r-4{ty(8xQ(^*uWS7}erb$nYWqeV^^J}C&;Ve7R{%M{ zvtRv3{YD*jSpb0WF#v!b@wYPLL;#@L9{`}3`db-u1^__#0RX7#|6AGLY+`TZX!M6T z4Al9KnHd0ZkPiUhYXbnp0|3B1oj>YOr+=dx1FDJ!rI#J*We%_gm;x983IJOG1i*!& zcmYoUJOII4BtRN~e&^1wH|oGZz3*b*y^Dcy_Z}7&CN|zZJUrZcxVZQP5ANd=5E0<w z-Y2_HL`*_TN{UBFPC-UO@qmPs<d+aMbW|IRyEu36;*j9u;*<PeAGgf_BJ4YDXm8Nb zm;iT((9ns{ZrcE~D0AOIM?)RIdketCLaB&;2Mq^Rt$81ShJl9C9tR5(=N`u0yMM4_ z+{Glqdca7`LqbZ%B=t<~%^)^8uZB@{4Di)!hj&9l>c&n{v6=7%W<CKyX_#MR`J$t9 z_8txkB&&tuA?x!plwfMq1^@A~0Munrg0WDQVnisV0C%v^aj?+u{zWlVB@xC0Vn!ad zS0s^xq*ChdGT|*ldv}?Dd`1pgyv7Us&m5!nzu!&)@X%3>iO`7vl7O2pSxk4B7?}R? z)KMSxB@|*{J0hU9RIVrf?rpl^bfM(q(zLV8vplcYN}t{sRmTs(`-xdXCQ{<$2NrHj zXZ<QAR2Q$2syozsx^*h?{#nXUkDi_m&$2b1Jr7|jmZ_wRNsd2dsCN~zetYD@mQJeg zE*X4kBUxkDBA(uUS(QF5G+%R7kbR`{jh0U3X4km&pa5t06a6RicJZ&@Jr@HP6ZhY& zMV~df3||IZ5qDl0)LUM+`rxeS(Pz!<xr<#k1{404P4Z_`&9vXTsj}|-Z0DcipRvX| z`2@SREbs1|{FiE_y&1gVoWqK4s-Gym`mKxVZ>zj<U%ug<skm8a!Z`3H-<k5YpYwCq zlv}U%`lA~?xe1w3o{1{QP!)@rUO$b7ciUp52?h8>vXyke7gM8)#k8N8KPFH&tpvig zH%E&Kf)p1u{j-G<Lj%hih9&MWu^Io<ApNVKZ}-`+MRFxmv~B@eE1$}0VsBE0()&1k z2OZlDKqrV*qgw#t7EmA&{PZea>tk@U2NS1C)@TigQA137t4|UKEO%m^477}$So5*q zW*|O!q+z~-pH(U@)BY1T@#{<NCh1nBhfZl(Iar>kJfX?)G!VvbGvbU<Fcw#R^gY^R zJfyG!Ob*?C()*nMvXbs$?c!64at}tRcl9kmptpBsyw+{GCN{@@QUP9z|K>w>WPto) zRBB2HPY>dUP-rnE1)GZ&jJ+WiV}*=z&Q6m5RJ)K)cN9npqukP`W_vx`K9>{?m6+y= zLjv(v;lxx2dN^DYk~Q1w8oc1DoE3{7MOlNXCeLNwq$eqz$wl7+*d&jW3S)IFxfkeE zuX1hyV^(FFGZwdipQTN9A>YhjTwP*+ta8Bq`1a$+|NX=uu6PSju~c0nlU%PgE-ZfN zo6nzr7)*bp3)k%ThMy<+?%x77`m+WkS;v@|_UM9>ziGXxm|^`1l8)+EZ{47IS#$Nq z`WxpYp7)K}69tSye8${a3uqnPbu}>O!RnW0qqEfSp$;WZje6XHotEUIPJ|tz@(dN@ z73~y;Mx2vx9q$MM-q!PwoAQ4wGA^gNy1Lfq(lOiw+Yon89$!eN(YTHdS>fFjb(&be z{%&PWmml6z>QsUBdNk^%fvKN#${2B{S&*@%kxJ~N9!_g0_lFTlb_oHKk|nlf8?|r@ z%YI|BE~C|g9^LO!6VWv04&%MJS%csy6{pdU-7fmO!$&7sHIzDFbI|$am2UfiZDa0H zIGI~wPVuYv)P)>Y$)^r`RCh~+PvK!kChw93K}o7y^Uk!Q#i`GAC#t7(w5w<Z*uOF{ zbru{d_+lUUuCU&ezlkcad3XivyamLhU-W7yUKY_`3zBc|$M^h9Pd6eW>tiIkPeg?F zzn(C#f((B4vRy4AuE{yxB)3hC)4Hna6ogksgjF0Q>ZqmcSUAaKY}fp(1rt)vUeqRE zv0is~UMf9D3~>fpKVBo!ZHi4h@efioGPUgXGzr#O5}VWH^ZjrOs1-N&+*q~SOF!kN z@2oS(j(3^E{YuRYp8GAP^Y2)bb1y^$&-7kSG7TsfD0{WPc%AT~l~-^fu8p+UKvaH_ zr%H~Xv6r`l@a9Rm*KfJ3(N0ZrEdQ0%v9rF@(Vn>l$sN@{FTXs)J=j_OeIo$z4;ID8 z-xQto95ya_Svc)#Oz+60VxA~9;VkjCF!tE(brl@}EjQ&0xefzRE|@cv?*S82eZ=$U z7|);nS0~cdOyJtCbeMry=hbZAT>ZgW|Mg9~(brpm#M0qrmh2MbUGD?Y9bfLy>-59T zt)l|8|9$xWO|53#yIvZPfo;ya2-Vdeu6oVe`^jOqfYJ3M50K!45&_(*rLjP}YSq>L zL3+dzNb(Y;L&m?F5dVD{H$qq-w~qlJ#C&nzCu>aMOX<Ei{M?GeeivF;N_1VY_Ini} z&Fj17P06j4`TFN_^5;sVoO)r>rS{^=WPkj5lO4Yy>}hIvo|oO$k%z}6=y9___pGp2 zjM2y~fb((X7u|PLa&M+wY*(eC`O8XI*Q&nn%r7n~N$qY^HRaSy_a(P3eI3Y-?0A!_ z!6Jo0q!l74JcSM(5LyQSSWqgMa&B;H#%ig0y}VMFU|Z23o+FVY>HlQCJIwNGDr>a* zwNctUb$o`x&Yf-vb_hvf(v!2<Nea)D;Tp+G4XD-r2Z=$V*Z=8p`ZXTpslh}eB^-z1 zawN*!p8YT|=5js}{b*8kn}3W&N4Iv+WIv56vmCAv67`U+?hf!fyJO(Lg(ag```cPB z>aGOA90OsPNF?0o2=lvmOLfMU-Zt~`3zFgG0!eObk!oJsy;_Q!v#EIz;4R=?O3}4p zx`KXU$PJ&W6wRb==nE5G@s3U|m5HP^%-WTVn$UiBy6whL)*8~veb#$s39vX_^Ua<M zJbbzu=b#EZuX2^eYzrt)%+%mCwccA_$BTVTzL`;pg{Hf?CGE<jHWhl9rOiFu@<yK_ zoz1)0ZWCmP@`j}mC+f6Jb93ja)S;7&(Vn&agk+7r(S=4wVFR8yJDy^DuFWki8}3oA zhK-)z0x^CQcf&sm_~0s()gLAnx=>cpPk!I>ubF>Pf2h+ZyS8<YC(ErhaKwx2`=<5G zCsB<sg%qSgLkEJoHf3OTbm47ucayzx?nO=?T;$;mLx*QwKFa4w`%Pqv?68b<m{ouj zd0ON1zj7&Qu#aw|)7Y50v#i-R2cd19dl1ciyB5?3t9}~cJJ`sKBrUa{Kiq9DgK8kV zO&5jJ#_!C}zFOjHNmWgrb)685--v-~ibplwhk$0(QQn#AZvwoLA~iI=7Y7rl<YMZe zE2w4tA>+fU8H9XSzD%%UIsFbU5%CS~ar$EI@mzW6h+B%kwHsHhfX<*|SMwc(h{`rT zL!8ZO2j$%JE?9x^&ns;V8k=W?8O?Kk`|n>2Aryb^E4w@x+(a(Pe~8|OJze(Q@YI!P zKjzABm7)V3n11f)d9U=d_C)J$+{c~|9j=GXhTJpt=NV}$)C;Z@6$o;N@~LK%0#y!T zC7O!%gmiHmlakOfP>90{^jsuK)l>fQU}%s&S&T}~^(44XT}u`8d0!-(*sjSJv-18* z*aRR-#9IM?5w{}+sBJ_~>h$|kR-qS>&8*h7aTuD8w|$`|KU_~Iss8ry_aXPzFZ^R- z%<FBu6aK|<j5mE!gIm{O1oUuA5&e4k>-^2*i{-teLhSDq%5F-wAA?!3|C7IPPqK|- zIbO<2<2g?3L^tER#^n<gq$z^|H^c;zv>GVTYCI%_!9Fw<7wq`LHdaTu$6J{8{uCdz zlCQ=LsoH^mt^pA0U%4$sn?PU0b|}M#p0r&s3AFd1%OEu~{@j_LE_lb$^My;#yFQR< z(G6+|wUg>nUE}FC{t~38)FY9_!edEH=gP8~D)2E}#p|=F8E-Ok(4ah`3Nw(W7a`5S z@NOV5YIjUwmz|X_VQ32adjIMEg9aDdHHVQa<pt*#eT_QJtz!3PEoT;ehFz*5J@dPj z&War6=J3O|YrB0%4Zn2S3}>Gw4q+^U(-)GYszefCcp6J>VT5im4m8kO7qUvP&^jfy zaG^r6{<E0CwEVmqY%W)>s#}B!ek`!ns&QDjTjA$sYT~soWO(z^YY1&3qCS>nwYc@z zaYQQ=lwq~k^m6e~vz$u9M$dNZT<HxpE~D46*;_{hiCUF#>Z5gObk}I_6MJyas4%Z^ zFGOR6XR=6=VqJ!Vw?ZU}r{g0`m+%=j^L<TSrjW6Too>m(lxRd!rGv)V8peG&{hTS^ zyT%q-A(*u{q<l)jPdD7bSW^HUkg6SSI~T)re82$0gQ`x9Tbk33UKfE^92-K)PVV^j zCwkH;=4pi|xzy%0k=(}%X<$C@s|ml*Rd|_#TCer{*lo8JE@+^qoZI84`TW*-X0AK8 z06s3=;iOaaua&HwNb-<6WVMByxMqg6?JeM8WKE*hdhWC}Vuvun<A~KJW_-CdsdcD% z`6$TiO{E4mTeSC<XEsNY@QjCM$I@_tl8F&k&HF*UADJEtz2@RK)o!?+-(4cJ!B06M zC37&et6^JX!VS>U7NbmAm3aVWt3qh<L(3U|d0n5BoCiWT@_<^zz*x-%X>`6l+_Hy2 zk{mZAa^zh^5bf-BKs#ca)zN3DuHe+(Tv8lSE=i7=ZqfC4B8#bW>EnO)k@=I-m0G^A zcX-OZZZd#@w+zYKRFHlkGclu{;ejneIf3H)N^r9$a?z|G(=_Z#Yl9($Z42uULus>B zE65@wg-;8#Atq%4`xKj+MGXjz3e_adfOs-^MlHe+2ZXkc#;(<1THw|P`d0EKP=zeg zi*|JWvr2KtldW^s(Al`oS7woyxUTwdDrX6n%@j{x><8BR54Hl|!gRq4HFj<cejtqd zg>e0w+}Nmo=CcNMdW{(~&B#IRgbG+~wBLYMpK&t;3-)7v5xm&`?GuX>F;h@zm>|DE zq2UBS!8}$5+Z93LZg}iCa(gK6s<iDEK+n3cay@d;N_^#&9vFZ6rZ4D>mT!)p?H7ai z|1$$-Nqfx}ODtl7T~YNGu)=fkaRaY1M1?G+nm6Kk=SskU05ypnGtcfemLwym8v&HI ziT&-Iaj|o8zag8IuE_{@nWhxGv{FGo^Mh)0tUH|$nErQ~v53{0`%lG=UhF}<G`wL8 zM7tVJn?1GxC5os`(F6I-@im*RhLC06$`p9>cPXAWu~r2@lmZun-GD$*R1wV$sAGEV zgLGoP@s?pb(BGX+laq1B5)f47X1O9B7~6N40v@a9#!b)t%-+o@)OpU9uCKKkw68!D zA|{es47kJ=vzEemfXylfwh`&>ugp6vvgQ25^cb}-_@5xeKbDQH@}W~~l*-}7aZP(> z6%hm7?Vdo=^ngj&VNa+>cYfAFWp`lU`J=8C?VwLsqLf<_?n}i#HaqmUr{_h&;YHN7 zeINivA&1XB?d`G7)zrI%MG)a)XkZ!D{*Q%nX{B(!R;sZ??CJBX%usjZlUBpT@W|7` zj~wP4<9^iRiysllgeez%Xv*S`)^uPpJr6#w_>NMF%IH5huw?ZQ^Vdyn)$2hnXWonq z1r8ZFhTGI#H=3T<NPCY5V^KmDNaK${ED>}0*XU|0ojlHLf=X0TnsRtv3s=S4+0-sR zme6zLs#8Q-k3i;-^Vw=(MP)NtBphp~f=?QvJQ+1Y;GAL_{6MRYqi=B|bn9CT5K`|x zArzi}ju56xDaOCxzNZOY{?cNbvk(4aEZB{=CoUmEu~4r&PVnKr6o%_g<G}y!0+IzQ zYr>6nCUq(~2Wo9KpFh^BMhXfWdm@RG#?oYA)dzugV*O}s2XBR5MKQTUl~T|qGq!85 zq0+5gSfm;Jy};mTY$$~NnM1VpUi=7WP&VADX&YB?h5NW_ZpiQ1zgRZkOB1htw?etQ z{!{L967ngGkb(gi#mo{HwXmS7NNa*H7FL*1Pe;+g$VP}X%uR{3$59EW#Oe>%dLrCS z{ULIjVPj6DXs*GghQUv&yA7Fk!<Cx+dM>=2pCbw!1uAhO<h<L%Wy$$4-=T(?wvM6u zpEf!&!BL%WS_hL>CX@9mq+Ng9EUcq%Ge-bu6&MJ<HgsBX%;qp7j9(Cl%srz(_hMU` zI!$dAqAQSHI&8#M92Oo+?4n*UoTGC5+AE{nq@<Q|sInQCCfk0(t;{G{+0VZ8<4SeC z|MFbYU1!fDzfQv3&anE`n#>D#`k-sdTYxX?E^Fjj`e6F$fzN>t*6UNb9v56^E~nRs zKTH3PbKY6i$qVuSfdF->gXJNQYM~bP$s*#<FgKdn^wowFqXXhMdtaM<w8M#N8<704 z&{Je=vo}+g<drc0PH9m~VkOzv{*{B+L&8tuGBmNBtYN9b=n2Eb`inpUE}+@FAwds8 z4M>G8<2I+Wrbv#pRN6D_o2av*%+i=m1O`bxHqZjX6sB2OWU&LlvcB`T?pFN&+}-~> zgI}1%BA!bHRT<ig1xh<w^j}TwrN0{m4Ia|^%_zl^>V$T!IW4tjG;&!*T)ZwLAc(iS zNtq$I1(>8DFLh=M#aV3gG7x1eLh-V@0zLMV`!%lcrGbT?_p0*LinRMAYHHy2-0vmb z(>?|ZqNcq^tAPkSPP_m?ap@<=>?Ry;{0>vwZ9S3K@c1Sdn5f)&;QDsyq5JDlIeUs* z0AN%!OLh^GOWT=Iwpm!-w^`<b_px^H7EsP<tyaE`k2U4@{2w1Ozsk`>a&zYoG1apH zw1unYnI~xj*Znux-Po6=63v=P&4`+M_RqQpF9O+xU2Z}@;@<+c@xkAiL*Cc8e#K<j za+}WowC%>(X+l(n<H_nMukXRnzvUk8f-K?>9UnQA-K9I9y?Al2gO0=`k{mEUgW#cl z*Q9<6XdK^I;-vSFdQ5g4Pgv33Ks&7;P)nQ0b@0v8ofRGiS+e6xISuPw?k-)MDcYQ7 z_99<sXFunpOz(hlK3bb9w$?2PDG_c=@ZXE%a0!WL-o}Q#Pton2S`E*ht}O#nnHe*Y z2S7vWT!~u6tLj6?%7i(fc_!a*35FU6Ul_ST^>x1WLjzw4A)S}9-TFgElM;S*yXmgR z^^4XQwMv83O6%H`8AJwa!#!!c<fpN0g?4mDdc|@}4VdYe&&t9C<1!yCL-lQ3;8oJV zPI1{zz+n|s34m2|KcB{OVCoPn+nN48pJ*OUTxPp*x)LoJG0g>B&CfE6Y>hs~Da_Y+ zoVGRak?m56nh1Mk6HUXQ#HmWwN+_t+s^;FgR~4bgPoZI#q=rr5A|8E<D<k5?L)Q3; zYQdFaq@{?f_zp(uc#kew^VGS--bp)K>Dg6f*I9jBa$4#7LSJoV%Kj^Np;gYwT^Ly8 zy{F)Lj!+JFWwmPMQ0<G|PX`H~^tqZ~9efhp*ad{*0`3sld7jHsWPW@MU46~FpW3IK z?ay*f3MI?^Fhv#U;o?Q;jxO|ux`;fRpEIgozNuaW;=D5>ysG97x27$zHL`ZLsoXEq z>lgRV)&<Kw!(JB)sF(WLE16$b_g)tHC1~^<Z-@Ay6~0<nm33#-TX~xGo%Y&0PuHFZ z%tyG((=<LK3YIje5Diq`xF=e!3gL6>d@->VYOy^Q*~7^lx=mm?aZK$eN($GtSl>Id zi<ZMJ`1xh;OxMSDjEhsvxrl{!POtgQ+?3fC->C<xuY~-Gu_dnqlF8k%?+#4r=mTmQ zP5M<!2&O5<MSap@YJawgZxf790OtwX@xP?gS1XsUnzqenmgQe*?fFT4^}KEII3>w; zGOjTJ{=jUxnw1?Yrx%4i)3dnUg0VYO)qH&P=;qUsKc{zWJ|?%4Rb)_okJZ}N((~6W z7MAvI4!*+Bw}-l`09R^P|9%?zg`L!b&(6i<0rqcZ#|f<Ep^ICTm+A`+#M5iGYo$cn zE=!G0Vg8G=0sWRU0*k3q!4O_Gxw}+MJ;wTmy_a<T-mOVgQnoXKlTtdpMKOH}8(cgc z)x?B&{3m5$6TVa&Ru<5H)(`cqCS{JaDaUlZr$pH92s61yP$}e4IUf~-S~DO;ZY)xh zb5uCh)n<g(JG&m-Y)XL(6gw}p_dHJ$Bv@tkd%bK^Z62R2WW=-za(GuS_tCwNY&1Y) z!OcJ7Jugeh{RswI$HLVk@)I7Llc~q7FopV$N##KVy&K*<`Hm9}JtLN#iGB{A<Z7AU za2{$_O)H1y=9XAOMeCCj>T6{cQY=FU34X}*Pw@r1%}67zomuieiC=HY;NfORwMsHu zW3RCI^g9mRnB5_KHK2>O;p+F(IxlrTr!KE(<F#{a?H;9gjz>IBBy7u+o%iWarmBIZ z$<F#DjEu-qF~^8!tkAggP~?mYDo_y~(zSQBi6`KBz6F_g+LH<^)HCar4t5C>a*R^I zGIY`1Z<&!)#p+0?xVbP*NR`Ve)N1r=R^X@%U&+MVu2;~1=x!1fW<PewU!SR#1eTWb zy&&%G)}##HALyuSUm~VN20!TqQv3vL_RN~2HMy*)DB9$6(Oid(LTV?*t#xYV%<(^7 zLyutuB^{Q^$|b_JLie}Zx+xT6Q~k+`C_*3SW%$}@!3f2B`|FXls}^<>4g3W0??2U{ zVh800kSiNAed;BSsUoNP*u!Mvu)(cqf?@LqIMfw2+whlFW3Vp9k%A@CD3_E`Cv(e{ z+~^4PioS&@qH{_~{iX6tcjS4dMmmdiXkp16rw^yr3bw6Qb;SIAT%O4^B@kEEhJy<H zk)_unJ?#QL%<{-L!GgPM#E9<edah7<9}dl4vHF{G9wr|9d`WSx=g(hz4Sf7~Pb?U5 zCQ;Xqq)6DrC1pd8wn;MwbMl4tv~OUyV2F)v6Rv)F5DtO&2I`q}<Eb-v9a%niG)C|h z21v9JQJfx%NjSP!(agZ9)AZjl{-a|hrL{rD-LeTac|V^R5X>VsFgY<+ChMJC39s?= zedLXiqFV`!YM;)V#NdMCE<T_@G8n<+=VF^Hk#^Ivh@rxeoqKs%2#M5=n$n093BIng zS<%XE2|vQ-9h`f$ozhW5I~-l3aqSx6LaZte&ZZ^fI(F9ks~x(pN}f%Prda1Sjka<l z;{!`hPAc@6%Jov$JW>{_JF7tWMe7?nBI2*BEL!;QxBCrO?%%N;04y1{sX|Ms->ts& z*ebkI`7o@7R}_-Qr)mtZWfx1_d7Vq5uGFO7b5cQWcM=;-@VQmXc7NrIxHVz2QL1F6 zhr4v}X{=<Ec{@Z{E3%_}dbh|@YkF*QVJxN|`p~Q+p;Z;a(${q~w6qA@F1L3#eOTXM zJC>T-WPiejo-~ef@_%_t)P7`yF~Uvm>X#>8>RmJj4PUM=B_0d9{)|19!)zjK-R8_s zbWSJ|2<#9%RN@p&#m*w5@@z{fqG{fpcGaKcB+V}uRy?x~z@qdd)GIU*zT{tuYLLoC z#hD|(09wq}3~l|Fi`OfvyOaB>j~sjs@=v$Ueu3K|1ugqziLI@Ref`9;lBB!yG?fYB z#4coX-T8_23YII#Okgc!{zxTtn4D9m)sV+6S)*<uEmU~HYsU3(ddzR<+i3g>0r%|d zo9lFvgFy1#$G$#)0Ml&KE%_Nv9TMME?$yDF!FY+YS1$*HLaRKEV=%XF4v(sLB}O*` z2R(dMjq^H;OSO8;1nr+!{2UH@WHN2-o-lwm?X*Fr`aev<R^Id?`*A7%&D)_1l@|E> zVYd+eRX%cPcs2>aGP7{C3qNfTV0)(KuFujDK|<T=O3&bMLXvw>HLnT#;3qM{xh8jX zcT_mRQhWAa+QU?X*#oH3`Z+y494*+)dCA<AT7}0Vv}&TeyLV=|(W5h`6b31VEPr2` zd$zn&X;$#H!#K@&?+}Qr>T|az-~zb<PD|{5TR8tu{mp$6RaVu1jz>*vq+-~X6VuYb z$nKm7wCDB`>;mG19Dc6QHCIx4<K&f}gU`kD(%MbR-ZbBzu2y6-3x?FJcqYkjZm|pf zzA@PTH2LIQo<f&fBm3f`ksmrucOUP~)=(e?vxAD_?3<<0r!jZ%3B>GXYLSglI2Ez7 z<@ru;@bE;T^|%ySPQK!!RAJD<N)gRmwR7^H690Y>SynOW11}6zzLoET?TA9W&A-Xh zs(etRNZIc?6^hM{sc~X<4Ld$?bI(-`^*uKyyanvET8E!w7_6kaUR5Wbt2BCTk0Ji0 z)@rd@iM>Sm&Y-TULkc{7@Tf}VTBC|cI-%YbLL{fU1a2N8L$;z9LsAeDok5yv|9p0$ zjYb#9jr4iBsLFL|t8i2z`P<ATK>~twWPu8?S<NcVb=_E~pyH&l%C`27$_}@e22&U= zJwA=i<Fkz?e!!wvEbY`n%xM}G0MRH9Pt9B_?tZHf4ba;JcKK=lYJydGpj(p9xIER< z6WQaXP&iCoT<(27^L~93qp^^jhGYdXJpP!^VH@T-8k~2u;w>aI(6***tmzWR(Rs8Q zZ7336Y7GFO5_iasbLRUhPgRp7__|rsbAFN*nZ+z|mN9YYQ~a8G!w@c_mtwB%MC04* zYQY^@{e0Vw5}jKi`WfNr_PaV&z>!xEX%UcmTJO~Q#gCgOM~~W<)XWo4YUxc*WVv6; z_1Cx-A&E8^BL5L8L1lB`v1{piuN`V<f?FHQzK_#y<F<BMbJsocp-jJ9A>-DV?W72^ zx{CXLnH9K@xTpHC^xJyonVA>aAjvlOD2wChIj8@r3}GwZe43Jx_n`D%&lzVH%h2^J zb54V(2?M8>S9}sU%T?qvB$9ajep4KHW3pbRTw|4#jv9JEU|0heRDsL>ZhIdCX2pXw zy6Yqt0+(LSUl9+ro;Ys&$SxWd(XdCl!R2vh-a^Er=7)p=JpdfqE@@YLPK+jV$AEeV zRT;}GZ+WY+mZi)RTnvYfu0pgTbVDqe#DESlqHt65naW5vSlXtLJ}pNWEI3FEFd!s` zS@A1D=T6@ru@!LB?@kf_t>@3D48FctDLBx<-T6cxVBRAB+X6bz|EUqV{|sTi<CBj? zh7RQ&G~)rCulUN7T%my~AMRrVKQr16qQWZCBGQ=yJ#AXEjbLr7Xq_bY>?Vw7T0plM z`1jS)J7Y~XlCbh`@kwZ>-}#iyvWxqPUuQ<d5n{XWZfj*%5{m>YIC*<u-4O{uEoxF2 zU2#p^eqWN`ZIvQ;A$SpTwpbKfPbV?^cz(=@1ZHdu5zNy+{X<86pWj2m`g<sFF<*p9 zzc|_LjE-oNof?#~YT%BVfQ2)MrH0tHXB;#%z6H>1PYv`znEtt!MP3BF$*uE<*YB3b z!Z0gt0fyS^S$6JAERIY}S%1WY@BSAzAlrkmCHPF(x4xPE=gyZNB*y!n%X=<$!^=E@ z<e!0S(<nFM<MDm@$bT2g_iEe@4fC9)>Im&laRGR~wo`5;%d8W4KDbom5?Ln>nsO9X zpMQWo*Th#v{bN;NEy7wFLR}w2dRlBJ$l)vwb0meIl#MfG<5cSQO;*nwbWl#~u0xWI zclljYn<suiLg74x5NV15bnsv)`ow+je|TKNCGoZefJH{oGEtSN-ujxlf$F4@G6LhX zQy91tx5Z(HfEUzv7|t7!MMxEw=#nD{v<?k36O^s8NF{CtI`kj_tLYXDMAJ|8y96Sw zsh(XWy3~b95?MXFsu;`j@+x60{G{PpO4|&r#-F~wxXWj*@bWhTC+9G+gD^0+J<Afk zZoJ3iHln@iT`V^ViVPkhI^3b%bZPVW^gO#}%h$A|yxL2h)@RnAqQQdWP;?x)mG%SW zp`D)+9M>Czo<Cszcj0@Y83i|u5h@k*XjG1wkH)>B@ojpTRdjthq-nCoW{TY7xvgDc zj?($cLEYjlpbPiIv{!z<Q%6CZmqbQn)I8%ph*B3Jzthu9vySty_+g`hc(Enx*WFMy zbv9`wqfK+J>W4`IDLk`v9RhysZ=0DT1TruKg@v~O=ss^5rn3GK);)ylv)e0R?3Y=X zT0)j0GkZPdC;SNAfCCjCtdDFLO>!mF5<}7+L&BRZE0t|6r^f1Gl<PRqiCv*yab$Jo zy{Fj~Wtg<?F$ksDI9FvwTzg2mPHgNH%?YSC#+%ESx?WXdy9ri@5%kxL5fWG0ur22k zm)%dm668QOd8ka9`P8y^^dv>26urd4Y_+xA=N!fVVj7lW`g_;*?z^Pk*TZM$TzU~@ zED-}#Lu-wQ3@BK%w(S&9KB^9R<gDS?r+niqI@GB{H}@2($`N%_T|*nbin(32=FP6v zWn)Wp9G#7h=}_{utMSV*oz*MjB#@-4ub^<k<W`NR?x}6ZO~$y*dAGi$iNWYaH1Da8 zed*XO00PE3;aFCi;-(*t@fkekcBzvfg*+-iZ$G_(uQrk2;4EI=0<;bdf^PwL(}JdW z+)LoCKLfCWcdsG6KHOKtzxi-M#1+Peg2LWUn)khL0fp`*?Y96ww{NKQDDiRBZP4Or za^U==W>aW)G9Z=;UT3-0xA$Waf&$o6JOF?>$Ht5_u_to;st;s<(i11A!bCNUVzS(P zrh&uQW6;2nN-|Bt=q~*q20x5eoz5=#dT1`nL>h`=ICjtEP6-Wl(#hv85J^`CXWWIe zF#0^WASzEMVrhRA$lGt#+6oNK9#VLjBH4u{wFcKti4`9S&kg)+Hxu{5JE2|}^YX{Q zS&Q4c&s(tzl9Ld9t3EnB74`c`f2X{-f$t_Abl-QrY*Q<2#UkH-dS9{mM9f&olKTCq z$D?hEhOvo-qt<_EwY=VaF=a#k!TyO(YKnJ8qh74E`Z`Uq&}oEF99iQG4T3+l3WIh8 zl{c1i=3U4OUxHIY$BHC0zoUJh4Gr~_;)14~NrPn(KE{72r~Jg&F#(lI^xN>*j|{MU zAC><xK=1s=-SDN<vCP6=QBo%RkMZ>Q?01?8R*3Xj+o2%=vX^1Wp_kt~J2ndSA!3QG zuPc!zr91xBnOn27ZC!nCSBn6b?akHb>U`sD)L`nE`*%@}+FFV0)NX807W4aFd*$$S zwcY}VsSx4gJ-)9JEN#qsw)%2y;T<apt-u2ELj({F@QqE7W*g(JD_W~V{ZmdB4tN>K zj4c^OuE6&9eE}%`#mg><;K1r3>>Zd=k}HP4MSF0cR_eL^&f;48Ae-AIJf_GAY{U3j zJb!qUmmox1?0q&ITkPX1B$(MkB7$xl;0=WQ)QMHnu(T}P4}8zVBO?Ft(`OP_M$eYY z8?y2;SQ}FKG8v*{ki?dP7zY*P9Md%G4$NsOE^VOJl+_Y93HY${l{v`Bs4DPnS(CU+ zu-0(>+=inw5Lt`OEdSStBx;6qh5VW!)&4R=T5%jo{L>66!=yItA_gCyM1Fe|)@1iA zL|=f+Jslc(qH1D%jNx?|=rIGQ%ffl^_nEM5-4our%9Bgfoiv1=E++v|q-wdw)}oO6 z9rss1MY=az6DEk>jM{@jBg3Gm7^5J=#k2gq`;z4Py8JU7#yJAmKwAssjpZ$1UslUk z8#tiRU~kUNk3ejW9rNRaLZ96|2<|f}r6P;{<Z8RPU(!7s$QOw~Ul6bmRnCp#MT4mh z-tEPByC~<F%e7&nmRE7(WZCo0>W1*8)s_n}VPc<&S(uUbo}G|6e~&xlMdqNOZExG* z?i!h&D18Xk<FUp;$Hku-2yg|5jJU({8XUtD-hvE*)|T<iBKVJv-cM&X2+f<;xpArn zjjiUXV<kTMSaE2F{GpNmgOCp?5Isc>YA^oTMWr<)daT=br8qS{{dSYXj#${CRnOI< z58NR{!S=?P_LyW+C$<QegTRwD3VWJjl{T10SI7?D#_hwpk#Y;TQx2};ptIwQ51kU! zZLsdu@$UPM<U|l!_j#YoakRIGPNo?iKGh_Zz+C?-3gvtkP6XUf)bDTA23-;>2${JW z@_u`Eoh0{^=1znOzbqkpnvdCzsF*g^QchdrA-PC+0dVTHZM}1up*Gu5_9pa%nX#-& zCx6o;cF03DNYs;&?$wLnt8^d1;WEszf@H2uf+z9P{P@mVZ7PJwbzz}cMDIFT{fkZ( z!sV5`V@!zJCf;o3$Pv6^aZk5(QoM=<CiZBN8<g^Vhw7)xD(C9M_I8~qRX<T~>3%a^ zF4|E8komAHsB;zXT^qR^h37QTlEKNWHRFUcw3xy&xQ?tiES+SMe?{KduTh#Kxox6q ztJ`FCjlk{^W6Si6*dR}!b;@M)LC_V~(n;iq%K<2koEU#2QaL&5YA=YrlQDEG80Zzk z!k|QhJ=tfuidC4c<F=~|snN+iYP^amwNk}~o3-~!i<2>L6~MRO#nW`A6YKTpq65Ta zpK?wY_^R&N$yvf;gtyp%BH*mFr@i9kZr1v)3iWbg6|QOfJ=-&Iq<0;MpgrL;A%#%> zEnseIRjW-q-ng~=*+Zx__$=4SfB*gfVA+1ciS}nwJ-g{~|B2eg(%piJS#;&$@v7`( z`6gWm9Rwchqp&y1;j=$kdX~i<{vG2v%<ri^Mg-~LL0BA5a~Ok6-ypD5h=#K16^5K6 zR{7^!K!VP+@4bpVA$h%_{e{KkyUu;iU*nm*(`NdRuAm{2K8Hh{k<^Y>+m-A#uhbe( zKg7zH*TXHPE0$ukHp9Abj<CYrTwD_`|H0Cu_3vVVrA+&Q7C|2muN|L0noR#dg;WX_ z%@&DnF+J*gcA_Yrnr-;(BE(OMpA%`(&k;@&g4T$o{C&tC!TM$YN_j9+74ot*uQGt^ zs4x)pnc!K^yGR5x%n=jq%~i<-PeZs^ZEiuvu7x;(>cikhJDP4XhgkFM*FR3l%5ai8 zy1vhx>(T%X58v(loOKPioV~NI#}<n)*>{uE`~gW(79*@T)*QElBtVS30xiyU7be5` zhP!L-5tUat#1mdg`zVNNR8a@Mn*Fq+(l%^T_ELh{oig9wPwcRQtmBjn-UiT}M{{6K z?KDWC&*1eS|5T|Q{01YbUR=yW{Toi=xvR5D2xG6Lg;)Wr${s@5l2b0a&zPq#JF-(` zw*pem=D*|lK5YTVJ+v5>gaZTPoE)=D-p#hZu*!Q?C7AwRD!LYQX|t`rGvuDy%YN#> z3?rTvL?5`rsVnla&=E$L+EQ;|;-eA|6Di!>nH!D|Imzgr4A5+h_<m#SB2H&cP6zq6 zgO-U52(1S1f=$jmBhRJR>~~DuMQ0yc^ETOJZZm`d*`|qBvErRE+#98KG+E^QlK9y3 z?6q(yV2eHX@jI&6VF=Eb)ZOJ~(6&^aft{UisbgLZTtnrnJEpm<QUaSbyCg`jFj%r# ziE?)Ahl8eiTk|{H)zn*xDI=3$pey};RNaD4$RbPXSVfV3)nQ(40lS^F?`!2muOVx^ zSM5_8;bjav*w9*eSJ_a%`WPYUJzoX;#p$Yw8_uKrD0-~5sO1w8rfIrfKg}AR`gg0< zMgwdghIt!Q<20*;Cx+HQ2*Ux<sMiiQioFmvn;NYH+OLcl6iZ!x16YHrMd&G#27JLy zi|V-#7Q#fqNfy&-LNX9GvEAA*+dimX=|^D?>>#)wp~GDzO*5h69>*@RY&Ne|{&qMg zPtS9i_e64h@$KFQc~F|1QT+GjRvhnB<IW*ieZF5hj{>V}<wsybQKXdtNne)YUkQ3W z3b82a<96&I)Rvq)u<N}lI*|hY)jhpShk!g6Jrwb6S9@yxGM6v}i9!q9|B4n&+5Tlm z@$A1?FaK#rq2_In`>;#CCr+RzoE=w8LV>oMHsN$Gav2>?9W`#V8s*u-&_x8UO6s$3 zwBb*++JCKPws~fmZJtgai3L%d-&&D`%E1r^N%EH(9;F9vpG3Z12Rw`Q9I$ILH)HuR zb6tNwyKg}w+Ewa93AL{v(<;AD^Ha^yma3~&koJ|)hNkMw>DREVh^rAxNf_cZ%ICoA zPs+;h7!?!mKDju`{*faoK&3jpf4!XEkp3r?lmJqnq31zv4uv!QFA@-TC5qy~Z;ESt z-yt^jX-11=N;R6|;deiOD7oVzL~AQsaXuReN1=xP%HOQ`(`%P5J0;x(p8N{wL#oGf z>0}tsKM(n=h3_(7FaM1iY1v3yKM$MJk8vjnT+LRvOp|PgK8^kxrH=L0GkaCc+e2;n z6@5}D>yF#af$FT(_s~b!Ktg)eo9tV_U$0`~a&eSZeGs$n5Qr=%bx8}|ra^N!LHj9b zsEtZHGJm5?8xpE*X+$x9GFCcFGfqRWRAEG2#=Tr`E`DT>k0GHKFsOwO;z_3t$X0Bj zrd)iN^$g^Xw7V{F%413J{Q1qj|GUUd?WOKnooNYGWypu;S`7<C_eoqiqn>Q)fn;i< z)xR@Tvvu4{%@4rD4ith`FJJQRxg6Ktv@@w8wE;Vf5DPv|4w;&6_0CT=-jl+-&0{Zq zx*RGAdP)l&*1z)p+dcK)_+pWdR>kNiHn+CtNDv0qh{50vzyAr3{dwd6kMwmZ1BWMq zAj|JxQrxATX$DWXkKoz>5SCB9r=&r~#N%&E!U9j~Jh}UTw(KvL3gwr+eHZGRIv}pj z;B}>+C?xqOd3R~sm&J{W%Hi$NNfwV<z!eEg262zf#SiMFSdfi<CJ}zKs{w>cVaQD~ zN{xo{AM#;>@impX2ty$dmimR?@cNRm7m!;!?Wp7jXk44@8Cff*#j<$M+fwfb9dQ+Y z3eyifVyN5xx`#(uNAeF(l`V|muN!`e{(s!?|KZO5C#JvI`|mfefAoyX8<O{UQ;#*F zS&z#`H&ww({=^A}KBdz$yly{d?~QS%GKF_!%vmt1cq;<0Z_pEduJv|hxtctg4gFBp zB{FabQF`&Z=&69Sr4vhLw9d<*gQe9lVS&5CU|l_2q2-#6uyZB?#(DSt;KNjs7mSiJ zE8rd>sElz#VH%DWbC^~N&3Hv<>pNO(VDaYPXHbzv0}CF%iJJl2xR|r4?eTg!W5Eww zr_y<b?#xrnyicwFxISF9X6R$wy6mWdlmH_kraJv;xz?~bYFct`4r51^1F?ei(iOvp z+2&+e5gngKJl0ywzA#3syeIv^%Wp^K2AQ~SeQJ1^DV`DChmKHALC`LDuY_1zLn#Kv z_^qja;X~gd{tlnH`2(NH6Bhf0YGt5ME%ARuwMc?cAkKec`e*3N4OxBnUb%KTzw_p^ zR_18M`7xdhTU~{909{lb5HtXa)EqzakLo<c!^hqnpX+Umg}!yWT75P|BX=Y8<T}|I zJig*=5(xWj7;?#kctUuyMtoghlOnr0#pLaa9dGLNLPyq}lmCrDNTtD;>@7gEBaL^| zSDX6UfGSkcL;gk|_k`ok=T8OuaRnDY<~GiTJHREIK;MET<v(h%jN8E<zcx`?H5G>5 zl!S_#e!VFZy7<OFlAja4GVk&qn!@DhC3Hz8l&Q)EGMzX*L{s+_C*M__CQT@$vK)?} z<xnP9trSA17-8K)jDCVHtLZ&0pZA2PE7cpJ$Jv!cbKVmtfkKzqx-0Er=teb@_YZ0- z$^?@HP0#kX0){b>Xj7$u_dy>bPuSw3+SiWs6WR?OE@D>$cq^RQPmY#6+pI01Qm*-i zfa{<ystWYo&0AUpUr05VI5m$Wn@0EZ|1?Qb%u(c313~+zN}@MDzs=*;{pQ-l?%2!n z&$eprljWqM2mRuOe%MI{%w@s?g%;FNzlJJS^X#wORzhtc_LN1lh#!`P#f8-dlo95p z=FRMKcu{iSR#KKeTOMs!EjdifbN)83&z{`M`|PuyUj=NjaPR|WzE8j`tL>Mf3#oj$ zDb}?_5>_Lw->wC_+Oa;Wc-U_0{N~kk%8(Jf`jJ31#8Pp+Seh@j9tGAdMi7-hHQZ`u zh>tj2Jvg`p;7G{VNMJ)LoF$_BS1e5?@nUEC=Fjp%mkOB8E<yDHHRuB(p`dy8{{J!H z+M^rNniX5dCbDu^;+6H}5d#AXN2y7JwMS@M`d$AIcS<3pvQ}TV+ViEYXu9u`rSGo` zjO6sq_QQ~zvUsUBCxSe05$tjm-%sz)gTzbbCWfc;&55stg3@(87fGsug0=b|1_YB+ zMi(Sz;!mlU^=iv*MFwVW!s(4L?n`Z8GaTcxK~SiIQpj@|z%QWm4;Vn5hV~blI)Fk` z)BY8j`X87+{s}Dnkw;Ykl8blw+~2PyoT9>IL}4gl6U@1>CN>2W1BlhOh9N6IXq#-| zyzU70%pa=h=AP)bC31W<T*Iq$E|+>OR=Zq;N~Yd+TdET~+&gK+$0gu)uQ*T6d4r&@ z6oe|;A6Gx2L<{chjzJ9DWTUg-S&uCS-9TEfdfhL*KhS+`7^eY`isN-zB~iX8iL-Cb zOHW0p+7`Z92{4;5_32V+FN9`uC5Fuzx<CVNVmqTJs+eE7*0GUG{?wCe|3q2kHdgF7 zO9d88cbT;c<1M#*F%i7zM29any&6yOYCMoG<Iss6izsQaap#U<WvGn|R4A!yG#ivu z;oz)$QJDfZOKQ-4UzuJmc2E-+-P<)dz#>0|U~_tSRY{}swQ9TTnAXZjza~wiw3iLz z<7Dk&inPLn^SXC18>~Z41KhP#BNLNdi23D<`5U)7<n|)_^Pe=3(uF>RTrbAHi*9mx z#CadHGr+_mRK39Qd|T=5=v474?yGL_e$jj31fV}0D-u6R16S2{tlZYDq2^Gm;3@5M z;|_O|CNZ~5Vs4ARA9s|_10SI`>{QhG>RYDjn0ZWqWP*AoiiMJByw~%+Al?o0hzFua z8?^@1;XlUC+4ov0BSwU54UZ+%2}A2iLOzj=WCO!3aSf=cb~NvgMI9`aSIrq~KHVHQ zJY#DOmHS!>hEjrOS;>K!rr?mk1b*9zX)UznReWa8J4`jvsJ&!1#~9ON-fx-XG?Jqi zV)UN{UU;P8t7|Ojw?3n~LARpnXm(<Gf}71)V%xte`TWq;KWm`wRF4n+JFP<EsJ>81 zp=mh1D-F-yvmbg7wN1h-rpFFpfii2hN~)+oXP^eCjPZ4I7tL}-$n+=Keex(^lSmmX zUv4_Dr%tS;%dVxhsP?|+DOo{ua8Ui7THq-*y6xa}J<+gq=2>QYs-5QD0*9Ky_68S~ z5yuquFH71XrmPBGJK9&k@1C-V`r4G4;N)rstZZhR?3g-#Qo>x`E4k4K2Wn0^wJP`0 z#E>d6-1q#ORn(Y*HBTp3I94Y>y~x<!3kg_ild?<tV(Nr2BrTr1<iNq|)4_0ofKHMN zi;2TYuKgWM4JdfBUcDw1De^7N9H7p;4T;X8J846JcJLswD}7=BP%gxvTdiHNw%(<Y z$IMLg!$fT1wyCSE>`Z^k(z?Zl=EF^8NI|9KUAOUk0n)7$4SW7gtxtWjq2N<iKQoaI zHf<cQr*@QwiRD$U8UE(`bj6;EgA4D?M-JCSj+I5n!sKWbI$BK}I6;hegyg!U_2}6f zmTFl|I;nNf<i^~&j&z4(76WRj(A6Bkcvo-sYEp9cWpYC)Le$m$#@{zpB!rE;m>;jX zKR@6u$A-JB4&C-7APg4G3j0d>b-kwnKHQe#L=e`)(}GbPW*&FA^*juKO5@m3aD7Ti zZL!?sOjg`taWOdAZw8-GJP?bFTDFnX3v(?ckHEPkp8=@_{7{m8T<ald+K5~D+C)(6 zR4XV4OX3M(+JNrNk7)v<?boxxZHs3?2Y!p7ZE3}CudF$}Y{cK_3|483776%F?m~32 z@9x6hx%wuSoiDN^*6fJd9}?#ts9BOB{JH{qRYG35s0BTanpm=5wq&)<&9P}x?we0_ z?*;>1+&vu)9@P#uycy9ZpdZMT$f?)V=6h+-5;$+a2d<3WQ(%Uq)4WRS;ua|MoZjWP zmQyjRq7w=RZ=#m;3ap2yEH<v%cqRQ>5cZ3xXJcB2>DaJ@PVtG(Wd{owh?AL29;q3f zI|%d1T&yY?B&n>z?DG(-auDfJ#y$pZI6N8t;v{RpKUgZrYdq6T`&O*lVYX7ErnBHE z2Rv3}^Yj`9glVN<lC#q)X8P%Ha>bjKmxn26_g1xUTY?hAzqxD$brd>;mwa}9%;gmP za=$E({Ed_<dE5`h9z%F&V|spAK(`GJc#_G)*$l=!%L>|B#Xn4hGn1O`sjIuI<`yZj z<X3+w(yTdSb&;d$B4=w5s2@)vtIPo9L^nN}7IqEGsdQi(j#C)LE3XJMV=r>B<z4Ox zxbi$N<T}(Art_l#kJBrutob}Gl6hs$Ru1%U(s+u?Qo?LPT<s~ZAsF*RA#P{_I!wD- zu1J~Y8?$=Hx1zW<)l0ZyKXjI!9?~^jx>yw~ms<H;Cb$O`q6+^@hzjz)I#ZUL3Rk?p z{KqA08IOmbgm-JB?L@emd<-g%^<;B;dzVB`SM^!Ue}P>;hN-?0P8xf%OA}?B@2z5> zYDTaxcJnC2{3PADW<*Zg(sEnd7DCc|6=F@)|2ksOma!I}07P{XrKGU#$|+_`(`y-9 zAPieOMcnk?i1F)ceQ%mwcnn!V%Q{H9417=c@sl=Z6I*y<2Tn3xWIJt5I&f<3J{pv2 zjZkGNgEh-o7(f5(xfF}pthy$$PkrCH+x-$hPWA4FC#Iu7C`8D?H3@C5`0{Oj3~yzb zUV)L%@H2Nazo*(9Tvy|z$Cj7a+R(JoN>+`*n8q2!Dd(CDD!KOqKZVymY!s;`e!fu6 z#Mdo4=_YkctsX!NooVI=tQ`vDcU&uWhx=Dr?yV7K@7MOsG8I3@W+;ltG>t^A1Z5Vj zx-vqm(LZxeT#^ius(;a%E`~SUYliT+iCR}jC!RHIR#H}rZ+ne%)s_kLC&XDxZR^!o z*uh+o!$1AzzljkQGK{$L)ox2WW^>5N(zJK<lCt#3Spm^PS?ver#On<O@3lM^VDHpg z^ilP&qs=;~d)C`i1yRz2Kmti4%f{QKirhVATm_0e-F2lfYTF#EOJp|R&Fc$MbPt+V z(°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 literal 30104 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 S?F<&7j~_pA#Dr2mr~U_C`zs#+ diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..658490900cca60fc511e729fc08e8ea5e411d60f GIT binary patch literal 22551 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 X#QkvbKyG|{M>hY_zYE5pClmhyr^>}D literal 0 HcmV?d00001 diff --git a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts index c44912ebf8d94..3b65d307ce385 100644 --- a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts +++ b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts @@ -5,11 +5,13 @@ * 2.0. */ +import type { ElasticsearchClient } from '@kbn/core/server'; import { ToolingLog } from '@kbn/tooling-log'; import fs from 'fs/promises'; import path from 'path'; import { ActionsClientChatOpenAI, + type ActionsClientLlm, ActionsClientSimpleChatModel, } from '@kbn/langchain/server/language_models'; import type { Logger } from '@kbn/logging'; @@ -17,6 +19,11 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; import { FakeLLM } from '@langchain/core/utils/testing'; import { createOpenAIFunctionsAgent } from 'langchain/agents'; import { getDefaultAssistantGraph } from '../server/lib/langchain/graphs/default_assistant_graph/graph'; +import { getDefaultAttackDiscoveryGraph } from '../server/lib/attack_discovery/graphs/default_attack_discovery_graph'; + +interface Drawable { + drawMermaidPng: () => Promise<Blob>; +} // Just defining some test variables to get the graph to compile.. const testPrompt = ChatPromptTemplate.fromMessages([ @@ -34,7 +41,7 @@ const createLlmInstance = () => { return mockLlm; }; -async function getGraph(logger: Logger) { +async function getAssistantGraph(logger: Logger): Promise<Drawable> { const agentRunnable = await createOpenAIFunctionsAgent({ llm: mockLlm, tools: [], @@ -51,16 +58,49 @@ async function getGraph(logger: Logger) { return graph.getGraph(); } -export const draw = async () => { +async function getAttackDiscoveryGraph(logger: Logger): Promise<Drawable> { + const mockEsClient = {} as unknown as ElasticsearchClient; + + const graph = getDefaultAttackDiscoveryGraph({ + anonymizationFields: [], + esClient: mockEsClient, + llm: mockLlm as unknown as ActionsClientLlm, + logger, + replacements: {}, + size: 20, + }); + + return graph.getGraph(); +} + +export const drawGraph = async ({ + getGraph, + outputFilename, +}: { + getGraph: (logger: Logger) => Promise<Drawable>; + outputFilename: string; +}) => { const logger = new ToolingLog({ level: 'info', writeTo: process.stdout, }) as unknown as Logger; logger.info('Compiling graph'); - const outputPath = path.join(__dirname, '../docs/img/default_assistant_graph.png'); + const outputPath = path.join(__dirname, outputFilename); const graph = await getGraph(logger); const output = await graph.drawMermaidPng(); const buffer = Buffer.from(await output.arrayBuffer()); logger.info(`Writing graph to ${outputPath}`); await fs.writeFile(outputPath, buffer); }; + +export const draw = async () => { + await drawGraph({ + getGraph: getAssistantGraph, + outputFilename: '../docs/img/default_assistant_graph.png', + }); + + await drawGraph({ + getGraph: getAttackDiscoveryGraph, + outputFilename: '../docs/img/default_attack_discovery_graph.png', + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts index 9e8a0b5d2ac90..ee54e9c451ea2 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts @@ -6,7 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; -import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; +import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; export const getAttackDiscoverySearchEsMock = () => { const searchResponse: estypes.SearchResponse<EsAttackDiscoverySchema> = { diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts index 7e20e292a9868..473965a835f14 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts @@ -8,7 +8,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; type ConversationsDataClientContract = PublicMethodsOf<AIAssistantConversationsDataClient>; export type ConversationsDataClientMock = jest.Mocked<ConversationsDataClientContract>; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index b52e7db536a3d..d53ceaa586975 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -26,7 +26,7 @@ import { GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts index def0a81acea37..ae736c77c30ef 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts @@ -16,7 +16,7 @@ import { getPromptsSearchEsMock } from './prompts_schema.mock'; import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types'; import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock'; import { getAttackDiscoverySearchEsMock } from './attack_discovery_schema.mock'; -import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; +import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; export const responseMock = { create: httpServerMock.createResponseFactory, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index 08912f41a8bbc..4cde64424ed7e 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -11,7 +11,7 @@ import type { AuthenticatedUser, Logger, ElasticsearchClient } from '@kbn/core/s import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { Subject } from 'rxjs'; -import { attackDiscoveryFieldMap } from '../ai_assistant_data_clients/attack_discovery/field_maps_configuration'; +import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration'; import { getDefaultAnonymizationFields } from '../../common/anonymization'; import { AssistantResourceNames, GetElser } from '../types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; @@ -34,7 +34,7 @@ import { AIAssistantKnowledgeBaseDataClient, GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; const TOTAL_FIELDS_LIMIT = 2500; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts new file mode 100644 index 0000000000000..d149b8c4cd44d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Example } from 'langsmith/schemas'; + +export const exampleWithReplacements: Example = { + id: '5D436078-B2CF-487A-A0FA-7CB46696F54E', + created_at: '2024-10-10T23:01:19.350232+00:00', + dataset_id: '0DA3497B-B084-4105-AFC0-2D8E05DE4B7C', + modified_at: '2024-10-10T23:01:19.350232+00:00', + inputs: {}, + outputs: { + attackDiscoveries: [ + { + title: 'Critical Malware and Phishing Alerts on host e1cb3cf0-30f3-4f99-a9c8-518b955c6f90', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.', + }, + ], + replacements: { + '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', + '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', + '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', + '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', + '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', + '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', + '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', + '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', + 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', + 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', + 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', + }, + }, + runs: [], +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts new file mode 100644 index 0000000000000..23c9c08ff5080 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Run } from 'langsmith/schemas'; + +export const runWithReplacements: Run = { + id: 'B7B03FEE-9AC4-4823-AEDB-F8EC20EAD5C4', + inputs: {}, + name: 'test', + outputs: { + attackDiscoveries: [ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` by the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` and user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` involving the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ], + replacements: { + '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', + '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', + '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', + '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', + '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', + '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', + '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', + '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', + 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', + 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', + 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', + }, + }, + run_type: 'evaluation', +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts new file mode 100644 index 0000000000000..c6f6f09f1d9ae --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts @@ -0,0 +1,911 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +export const DEFAULT_EVAL_ANONYMIZATION_FIELDS: AnonymizationFieldResponse[] = [ + { + id: 'Mx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: '_id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'NB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: '@timestamp', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'NR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.availability_zone', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Nh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.provider', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Nx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.region', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'OB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'destination.ip', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'OR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'dns.question.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Oh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'dns.question.type', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ox09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.category', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'PB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.dataset', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'PR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.module', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ph09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.outcome', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Px09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.Ext.original.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'QB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.hash.sha256', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'QR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Qh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Qx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'group.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'RB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'group.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'RR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.asset.criticality', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Rh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.name', + allowed: true, + anonymized: true, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Rx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.os.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'SB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.os.version', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'SR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.risk.calculated_level', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Sh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.risk.calculated_score_norm', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Sx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.original_time', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'TB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.risk_score', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'TR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.description', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Th09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Tx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.references', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'UB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.framework', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'UR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Uh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ux09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'VB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'VR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Vh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Vx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'WB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'WR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Wh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.severity', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Wx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.workflow_status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'XB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'message', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'XR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'network.protocol', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Xh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.args', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Xx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.exists', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'YB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.signing_id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'YR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Yh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.subject_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Yx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.trusted', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ZB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.command_line', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ZR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.executable', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Zh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.exit_code', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Zx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.bytes_compressed_present', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'aB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.all_names', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'aR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.primary.matches', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ah09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.primary.signature.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ax09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.token.integrity_level_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.md5', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.sha1', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.sha256', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.args', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.args_count', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ch09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.exists', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.subject_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.trusted', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.command_line', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.executable', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.pe.original_file_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.pid', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ex09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.working_directory', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.feature', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.data', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.entropy', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.extension', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.metrics', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.operation', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.score', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.version', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'rule.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'rule.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'source.ip', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'iB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.framework', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'iR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ih09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ix09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.asset.criticality', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.domain', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.name', + allowed: true, + anonymized: true, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.risk.calculated_level', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.risk.calculated_score_norm', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, +]; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts new file mode 100644 index 0000000000000..93d442bad5e9b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExampleInput, ExampleInputWithOverrides } from '.'; + +const validInput = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [{ pageContent: 'content', metadata: { key: 'value' } }], + combinedGenerations: 'gen1gen2', + combinedRefinements: 'ref1ref2', + errors: ['error1', 'error2'], + generationAttempts: 1, + generations: ['gen1', 'gen2'], + hallucinationFailures: 0, + maxGenerationAttempts: 5, + maxHallucinationFailures: 2, + maxRepeatedGenerations: 3, + refinements: ['ref1', 'ref2'], + refinePrompt: 'refine prompt', + replacements: { key: 'replacement' }, + unrefinedResults: null, +}; + +describe('ExampleInput Schema', () => { + it('validates a correct ExampleInput object', () => { + expect(() => ExampleInput.parse(validInput)).not.toThrow(); + }); + + it('throws given an invalid ExampleInput', () => { + const invalidInput = { + attackDiscoveries: 'invalid', // should be an array or null + }; + + expect(() => ExampleInput.parse(invalidInput)).toThrow(); + }); + + it('removes unknown properties', () => { + const hasUnknownProperties = { + ...validInput, + unknownProperty: 'unknown', // <-- should be removed + }; + + const parsed = ExampleInput.parse(hasUnknownProperties); + + expect(parsed).not.toHaveProperty('unknownProperty'); + }); +}); + +describe('ExampleInputWithOverrides Schema', () => { + it('validates a correct ExampleInputWithOverrides object', () => { + const validInputWithOverrides = { + ...validInput, + overrides: { + attackDiscoveryPrompt: 'ad prompt override', + refinePrompt: 'refine prompt override', + }, + }; + + expect(() => ExampleInputWithOverrides.parse(validInputWithOverrides)).not.toThrow(); + }); + + it('throws when given an invalid ExampleInputWithOverrides object', () => { + const invalidInputWithOverrides = { + attackDiscoveries: null, + overrides: 'invalid', // should be an object + }; + + expect(() => ExampleInputWithOverrides.parse(invalidInputWithOverrides)).toThrow(); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts new file mode 100644 index 0000000000000..8183695fd7d2f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { z } from '@kbn/zod'; + +const Document = z.object({ + pageContent: z.string(), + metadata: z.record(z.string(), z.any()), +}); + +type Document = z.infer<typeof Document>; + +/** + * Parses the input from an example in a LangSmith dataset + */ +export const ExampleInput = z.object({ + attackDiscoveries: z.array(AttackDiscovery).nullable().optional(), + attackDiscoveryPrompt: z.string().optional(), + anonymizedAlerts: z.array(Document).optional(), + combinedGenerations: z.string().optional(), + combinedRefinements: z.string().optional(), + errors: z.array(z.string()).optional(), + generationAttempts: z.number().optional(), + generations: z.array(z.string()).optional(), + hallucinationFailures: z.number().optional(), + maxGenerationAttempts: z.number().optional(), + maxHallucinationFailures: z.number().optional(), + maxRepeatedGenerations: z.number().optional(), + refinements: z.array(z.string()).optional(), + refinePrompt: z.string().optional(), + replacements: Replacements.optional(), + unrefinedResults: z.array(AttackDiscovery).nullable().optional(), +}); + +export type ExampleInput = z.infer<typeof ExampleInput>; + +/** + * The optional overrides for an example input + */ +export const ExampleInputWithOverrides = z.intersection( + ExampleInput, + z.object({ + overrides: ExampleInput.optional(), + }) +); + +export type ExampleInputWithOverrides = z.infer<typeof ExampleInputWithOverrides>; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts new file mode 100644 index 0000000000000..8ea30103c0768 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDefaultPromptTemplate } from '.'; + +describe('getDefaultPromptTemplate', () => { + it('returns the expected prompt template', () => { + const expectedTemplate = `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": + +[BEGIN rubric] +1. Is the submission non-empty and not null? +2. Is the submission well-formed JSON? +3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? +4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? +5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? +6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? +7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? +[END rubric] + +[BEGIN DATA] +{input} +[BEGIN submission] +{output} +[END submission] +[BEGIN expected response] +{reference} +[END expected response] +[END DATA] + +{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. + +Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; + + const result = getDefaultPromptTemplate(); + + expect(result).toBe(expectedTemplate); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts new file mode 100644 index 0000000000000..08e10f00e7f77 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultPromptTemplate = + () => `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": + +[BEGIN rubric] +1. Is the submission non-empty and not null? +2. Is the submission well-formed JSON? +3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? +4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? +5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? +6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? +7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? +[END rubric] + +[BEGIN DATA] +{input} +[BEGIN submission] +{output} +[END submission] +[BEGIN expected response] +{reference} +[END expected response] +[END DATA] + +{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. + +Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts new file mode 100644 index 0000000000000..c261f151b99ab --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash/fp'; + +import { getExampleAttackDiscoveriesWithReplacements } from '.'; +import { exampleWithReplacements } from '../../../__mocks__/mock_examples'; + +describe('getExampleAttackDiscoveriesWithReplacements', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); + + expect(result).toEqual([ + { + title: 'Critical Malware and Phishing Alerts on host SRVMAC08', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}.', + }, + ]); + }); + + it('returns an empty entitySummaryMarkdown when the entitySummaryMarkdown is missing', () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + exampleWithReplacements.outputs?.attackDiscoveries?.[0] + ); + + const exampleWithMissingEntitySummaryMarkdown = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + attackDiscoveries: [missingEntitySummaryMarkdown], + }, + }; + + const result = getExampleAttackDiscoveriesWithReplacements( + exampleWithMissingEntitySummaryMarkdown + ); + + expect(result).toEqual([ + { + title: 'Critical Malware and Phishing Alerts on host SRVMAC08', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: '', + }, + ]); + }); + + it('throws when an example is undefined', () => { + expect(() => getExampleAttackDiscoveriesWithReplacements(undefined)).toThrowError(); + }); + + it('throws when the example is missing attackDiscoveries', () => { + const missingAttackDiscoveries = { + ...exampleWithReplacements, + outputs: { + replacements: { ...exampleWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => + getExampleAttackDiscoveriesWithReplacements(missingAttackDiscoveries) + ).toThrowError(); + }); + + it('throws when attackDiscoveries is null', () => { + const nullAttackDiscoveries = { + ...exampleWithReplacements, + outputs: { + attackDiscoveries: null, + replacements: { ...exampleWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getExampleAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); + }); + + it('returns the original attack discoveries when replacements are missing', () => { + const missingReplacements = { + ...exampleWithReplacements, + outputs: { + attackDiscoveries: [...exampleWithReplacements.outputs?.attackDiscoveries], + }, + }; + + const result = getExampleAttackDiscoveriesWithReplacements(missingReplacements); + + expect(result).toEqual(exampleWithReplacements.outputs?.attackDiscoveries); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts new file mode 100644 index 0000000000000..8fc5de2a08ed1 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; +import type { Example } from 'langsmith/schemas'; + +import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; + +export const getExampleAttackDiscoveriesWithReplacements = ( + example: Example | undefined +): AttackDiscoveries => { + const exampleAttackDiscoveries = example?.outputs?.attackDiscoveries; + const exampleReplacements = example?.outputs?.replacements ?? {}; + + // NOTE: calls to `parse` throw an error if the Example input is invalid + const validatedAttackDiscoveries = AttackDiscoveries.parse(exampleAttackDiscoveries); + const validatedReplacements = Replacements.parse(exampleReplacements); + + const withReplacements = getDiscoveriesWithOriginalValues({ + attackDiscoveries: validatedAttackDiscoveries, + replacements: validatedReplacements, + }); + + return withReplacements; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts new file mode 100644 index 0000000000000..bd22e5d952b07 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash/fp'; + +import { getRunAttackDiscoveriesWithReplacements } from '.'; +import { runWithReplacements } from '../../../__mocks__/mock_runs'; + +describe('getRunAttackDiscoveriesWithReplacements', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getRunAttackDiscoveriesWithReplacements(runWithReplacements); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + runWithReplacements.outputs?.attackDiscoveries?.[0] + ); + + const runWithMissingEntitySummaryMarkdown = { + ...runWithReplacements, + outputs: { + ...runWithReplacements.outputs, + attackDiscoveries: [missingEntitySummaryMarkdown], + }, + }; + + const result = getRunAttackDiscoveriesWithReplacements(runWithMissingEntitySummaryMarkdown); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: '', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it('throws when the run is missing attackDiscoveries', () => { + const missingAttackDiscoveries = { + ...runWithReplacements, + outputs: { + replacements: { ...runWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getRunAttackDiscoveriesWithReplacements(missingAttackDiscoveries)).toThrowError(); + }); + + it('throws when attackDiscoveries is null', () => { + const nullAttackDiscoveries = { + ...runWithReplacements, + outputs: { + attackDiscoveries: null, + replacements: { ...runWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getRunAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); + }); + + it('returns the original attack discoveries when replacements are missing', () => { + const missingReplacements = { + ...runWithReplacements, + outputs: { + attackDiscoveries: [...runWithReplacements.outputs?.attackDiscoveries], + }, + }; + + const result = getRunAttackDiscoveriesWithReplacements(missingReplacements); + + expect(result).toEqual(runWithReplacements.outputs?.attackDiscoveries); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts new file mode 100644 index 0000000000000..01193320f712b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; +import type { Run } from 'langsmith/schemas'; + +import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; + +export const getRunAttackDiscoveriesWithReplacements = (run: Run): AttackDiscoveries => { + const runAttackDiscoveries = run.outputs?.attackDiscoveries; + const runReplacements = run.outputs?.replacements ?? {}; + + // NOTE: calls to `parse` throw an error if the Run Input is invalid + const validatedAttackDiscoveries = AttackDiscoveries.parse(runAttackDiscoveries); + const validatedReplacements = Replacements.parse(runReplacements); + + const withReplacements = getDiscoveriesWithOriginalValues({ + attackDiscoveries: validatedAttackDiscoveries, + replacements: validatedReplacements, + }); + + return withReplacements; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts new file mode 100644 index 0000000000000..829e27df73f14 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PromptTemplate } from '@langchain/core/prompts'; +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import { loadEvaluator } from 'langchain/evaluation'; + +import { type GetCustomEvaluatorOptions, getCustomEvaluator } from '.'; +import { getDefaultPromptTemplate } from './get_default_prompt_template'; +import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; +import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; +import { exampleWithReplacements } from '../../__mocks__/mock_examples'; +import { runWithReplacements } from '../../__mocks__/mock_runs'; + +const mockLlm = jest.fn() as unknown as ActionsClientLlm; + +jest.mock('langchain/evaluation', () => ({ + ...jest.requireActual('langchain/evaluation'), + loadEvaluator: jest.fn().mockResolvedValue({ + evaluateStrings: jest.fn().mockResolvedValue({ + key: 'correctness', + score: 0.9, + }), + }), +})); + +const options: GetCustomEvaluatorOptions = { + criteria: 'correctness', + key: 'attack_discovery_correctness', + llm: mockLlm, + template: getDefaultPromptTemplate(), +}; + +describe('getCustomEvaluator', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns an evaluator function', () => { + const evaluator = getCustomEvaluator(options); + + expect(typeof evaluator).toBe('function'); + }); + + it('calls loadEvaluator with the expected arguments', async () => { + const evaluator = getCustomEvaluator(options); + + await evaluator(runWithReplacements, exampleWithReplacements); + + expect(loadEvaluator).toHaveBeenCalledWith('labeled_criteria', { + criteria: options.criteria, + chainOptions: { + prompt: PromptTemplate.fromTemplate(options.template), + }, + llm: mockLlm, + }); + }); + + it('calls evaluateStrings with the expected arguments', async () => { + const mockEvaluateStrings = jest.fn().mockResolvedValue({ + key: 'correctness', + score: 0.9, + }); + + (loadEvaluator as jest.Mock).mockResolvedValue({ + evaluateStrings: mockEvaluateStrings, + }); + + const evaluator = getCustomEvaluator(options); + + await evaluator(runWithReplacements, exampleWithReplacements); + + const prediction = getRunAttackDiscoveriesWithReplacements(runWithReplacements); + const reference = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); + + expect(mockEvaluateStrings).toHaveBeenCalledWith({ + input: '', + prediction: JSON.stringify(prediction, null, 2), + reference: JSON.stringify(reference, null, 2), + }); + }); + + it('returns the expected result', async () => { + const evaluator = getCustomEvaluator(options); + + const result = await evaluator(runWithReplacements, exampleWithReplacements); + + expect(result).toEqual({ key: 'attack_discovery_correctness', score: 0.9 }); + }); + + it('throws given an undefined example', async () => { + const evaluator = getCustomEvaluator(options); + + await expect(async () => evaluator(runWithReplacements, undefined)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts new file mode 100644 index 0000000000000..bcabe410c1b72 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import { PromptTemplate } from '@langchain/core/prompts'; +import type { EvaluationResult } from 'langsmith/evaluation'; +import type { Run, Example } from 'langsmith/schemas'; +import { CriteriaLike, loadEvaluator } from 'langchain/evaluation'; + +import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; +import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; + +export interface GetCustomEvaluatorOptions { + /** + * Examples: + * - "conciseness" + * - "relevance" + * - "correctness" + * - "detail" + */ + criteria: CriteriaLike; + /** + * The evaluation score will use this key + */ + key: string; + /** + * LLm to use for evaluation + */ + llm: ActionsClientLlm; + /** + * A prompt template that uses the {input}, {submission}, and {reference} variables + */ + template: string; +} + +export type CustomEvaluator = ( + rootRun: Run, + example: Example | undefined +) => Promise<EvaluationResult>; + +export const getCustomEvaluator = + ({ criteria, key, llm, template }: GetCustomEvaluatorOptions): CustomEvaluator => + async (rootRun, example) => { + const chain = await loadEvaluator('labeled_criteria', { + criteria, + chainOptions: { + prompt: PromptTemplate.fromTemplate(template), + }, + llm, + }); + + const exampleAttackDiscoveriesWithReplacements = + getExampleAttackDiscoveriesWithReplacements(example); + + const runAttackDiscoveriesWithReplacements = getRunAttackDiscoveriesWithReplacements(rootRun); + + // NOTE: res contains a score, as well as the reasoning for the score + const res = await chain.evaluateStrings({ + input: '', // empty for now, but this could be the alerts, i.e. JSON.stringify(rootRun.outputs?.anonymizedAlerts, null, 2), + prediction: JSON.stringify(runAttackDiscoveriesWithReplacements, null, 2), + reference: JSON.stringify(exampleAttackDiscoveriesWithReplacements, null, 2), + }); + + return { key, score: res.score }; + }; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts new file mode 100644 index 0000000000000..423248aa5c3d6 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery } from '@kbn/elastic-assistant-common'; +import { omit } from 'lodash/fp'; + +import { getDiscoveriesWithOriginalValues } from '.'; +import { runWithReplacements } from '../../__mocks__/mock_runs'; + +describe('getDiscoveriesWithOriginalValues', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getDiscoveriesWithOriginalValues({ + attackDiscoveries: runWithReplacements.outputs?.attackDiscoveries, + replacements: runWithReplacements.outputs?.replacements, + }); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + runWithReplacements.outputs?.attackDiscoveries?.[0] + ) as unknown as AttackDiscovery; + + const result = getDiscoveriesWithOriginalValues({ + attackDiscoveries: [missingEntitySummaryMarkdown], + replacements: runWithReplacements.outputs?.replacements, + }); + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: '', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts new file mode 100644 index 0000000000000..1ef88e2208d1f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + type AttackDiscovery, + Replacements, + replaceAnonymizedValuesWithOriginalValues, +} from '@kbn/elastic-assistant-common'; + +export const getDiscoveriesWithOriginalValues = ({ + attackDiscoveries, + replacements, +}: { + attackDiscoveries: AttackDiscovery[]; + replacements: Replacements; +}): AttackDiscovery[] => + attackDiscoveries.map((attackDiscovery) => ({ + ...attackDiscovery, + detailsMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.detailsMarkdown, + replacements, + }), + entitySummaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.entitySummaryMarkdown ?? '', + replacements, + }), + summaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.summaryMarkdown, + replacements, + }), + title: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.title, + replacements, + }), + })); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts new file mode 100644 index 0000000000000..132a819d44ec8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { loggerMock } from '@kbn/logging-mocks'; + +import { getEvaluatorLlm } from '.'; + +jest.mock('@kbn/langchain/server', () => ({ + ...jest.requireActual('@kbn/langchain/server'), + + ActionsClientLlm: jest.fn(), +})); + +const connectorTimeout = 1000; + +const evaluatorConnectorId = 'evaluator-connector-id'; +const evaluatorConnector = { + id: 'evaluatorConnectorId', + actionTypeId: '.gen-ai', + name: 'GPT-4o', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, +} as Connector; + +const experimentConnector: Connector = { + name: 'Gemini 1.5 Pro 002', + actionTypeId: '.gemini', + config: { + apiUrl: 'https://example.com', + defaultModel: 'gemini-1.5-pro-002', + gcpRegion: 'test-region', + gcpProjectID: 'test-project-id', + }, + secrets: { + credentialsJson: '{}', + }, + id: 'gemini-1-5-pro-002', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, +} as Connector; + +const logger = loggerMock.create(); + +describe('getEvaluatorLlm', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('getting the evaluation connector', () => { + it("calls actionsClient.get with the evaluator connector ID when it's provided", async () => { + const actionsClient = { + get: jest.fn(), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(actionsClient.get).toHaveBeenCalledWith({ + id: evaluatorConnectorId, + throwIfSystemAction: false, + }); + }); + + it("calls actionsClient.get with the experiment connector ID when the evaluator connector ID isn't provided", async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(null), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId: undefined, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(actionsClient.get).toHaveBeenCalledWith({ + id: experimentConnector.id, + throwIfSystemAction: false, + }); + }); + + it('falls back to the experiment connector when the evaluator connector is not found', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(null), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(ActionsClientLlm).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: experimentConnector.id, + }) + ); + }); + }); + + it('logs the expected connector names and types', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(evaluatorConnector), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(logger.info).toHaveBeenCalledWith( + `The ${evaluatorConnector.name} (openai) connector will judge output from the ${experimentConnector.name} (gemini) connector` + ); + }); + + it('creates a new ActionsClientLlm instance with the expected traceOptions', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(evaluatorConnector), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: 'test-api-key', + logger, + }); + + expect(ActionsClientLlm).toHaveBeenCalledWith( + expect.objectContaining({ + traceOptions: { + projectName: 'evaluators', + tracers: expect.any(Array), + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts new file mode 100644 index 0000000000000..236def9670d07 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { Logger } from '@kbn/core/server'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; + +import { getLlmType } from '../../../../../routes/utils'; + +export const getEvaluatorLlm = async ({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey, + logger, +}: { + actionsClient: PublicMethodsOf<ActionsClient>; + connectorTimeout: number; + evaluatorConnectorId: string | undefined; + experimentConnector: Connector; + langSmithApiKey: string | undefined; + logger: Logger; +}): Promise<ActionsClientLlm> => { + const evaluatorConnector = + (await actionsClient.get({ + id: evaluatorConnectorId ?? experimentConnector.id, // fallback to the experiment connector if the evaluator connector is not found: + throwIfSystemAction: false, + })) ?? experimentConnector; + + const evaluatorLlmType = getLlmType(evaluatorConnector.actionTypeId); + const experimentLlmType = getLlmType(experimentConnector.actionTypeId); + + logger.info( + `The ${evaluatorConnector.name} (${evaluatorLlmType}) connector will judge output from the ${experimentConnector.name} (${experimentLlmType}) connector` + ); + + const traceOptions = { + projectName: 'evaluators', + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: 'evaluators', + logger, + }), + ], + }; + + return new ActionsClientLlm({ + actionsClient, + connectorId: evaluatorConnector.id, + llmType: evaluatorLlmType, + logger, + temperature: 0, // zero temperature for evaluation + timeout: connectorTimeout, + traceOptions, + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts new file mode 100644 index 0000000000000..47f36bc6fb0e7 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash/fp'; +import type { Example } from 'langsmith/schemas'; + +import { getGraphInputOverrides } from '.'; +import { exampleWithReplacements } from '../../__mocks__/mock_examples'; + +const exampleWithAlerts: Example = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + }, +}; + +const exampleWithNoReplacements: Example = { + ...exampleWithReplacements, + outputs: { + ...omit('replacements', exampleWithReplacements.outputs), + }, +}; + +describe('getGraphInputOverrides', () => { + describe('root-level outputs overrides', () => { + it('returns the anonymizedAlerts from the root level of the outputs when present', () => { + const overrides = getGraphInputOverrides(exampleWithAlerts.outputs); + + expect(overrides.anonymizedAlerts).toEqual(exampleWithAlerts.outputs?.anonymizedAlerts); + }); + + it('does NOT populate the anonymizedAlerts key when it does NOT exist in the outputs', () => { + const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); + + expect(overrides).not.toHaveProperty('anonymizedAlerts'); + }); + + it('returns replacements from the root level of the outputs when present', () => { + const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); + + expect(overrides.replacements).toEqual(exampleWithReplacements.outputs?.replacements); + }); + + it('does NOT populate the replacements key when it does NOT exist in the outputs', () => { + const overrides = getGraphInputOverrides(exampleWithNoReplacements.outputs); + + expect(overrides).not.toHaveProperty('replacements'); + }); + + it('removes unknown properties', () => { + const withUnknownProperties = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + unknownProperty: 'unknown', + }, + }; + + const overrides = getGraphInputOverrides(withUnknownProperties.outputs); + + expect(overrides).not.toHaveProperty('unknownProperty'); + }); + }); + + describe('overrides', () => { + it('returns all overrides at the root level', () => { + const exampleWithOverrides = { + ...exampleWithAlerts, + outputs: { + ...exampleWithAlerts.outputs, + overrides: { + attackDiscoveries: [], + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [], + combinedGenerations: 'combinedGenerations', + combinedRefinements: 'combinedRefinements', + errors: ['error'], + generationAttempts: 1, + generations: ['generation'], + hallucinationFailures: 2, + maxGenerationAttempts: 3, + maxHallucinationFailures: 4, + maxRepeatedGenerations: 5, + refinements: ['refinement'], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: [], + }, + }, + }; + + const overrides = getGraphInputOverrides(exampleWithOverrides.outputs); + + expect(overrides).toEqual({ + ...exampleWithOverrides.outputs?.overrides, + }); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts new file mode 100644 index 0000000000000..232218f4386f8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pick } from 'lodash/fp'; + +import { ExampleInputWithOverrides } from '../../example_input'; +import { GraphState } from '../../../graphs/default_attack_discovery_graph/types'; + +/** + * Parses input from an LangSmith dataset example to get the graph input overrides + */ +export const getGraphInputOverrides = (outputs: unknown): Partial<GraphState> => { + const validatedInput = ExampleInputWithOverrides.safeParse(outputs).data ?? {}; // safeParse removes unknown properties + + const { overrides } = validatedInput; + + // return all overrides at the root level: + return { + // pick extracts just the anonymizedAlerts and replacements from the root level of the input, + // and only adds the anonymizedAlerts key if it exists in the input + ...pick('anonymizedAlerts', validatedInput), + ...pick('replacements', validatedInput), + ...overrides, // bring all other overrides to the root level + }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts new file mode 100644 index 0000000000000..40b0f080fe54a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { Logger } from '@kbn/core/server'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { asyncForEach } from '@kbn/std'; +import { PublicMethodsOf } from '@kbn/utility-types'; + +import { DEFAULT_EVAL_ANONYMIZATION_FIELDS } from './constants'; +import { AttackDiscoveryGraphMetadata } from '../../langchain/graphs'; +import { DefaultAttackDiscoveryGraph } from '../graphs/default_attack_discovery_graph'; +import { getLlmType } from '../../../routes/utils'; +import { runEvaluations } from './run_evaluations'; + +export const evaluateAttackDiscovery = async ({ + actionsClient, + attackDiscoveryGraphs, + alertsIndexPattern, + anonymizationFields = DEFAULT_EVAL_ANONYMIZATION_FIELDS, // determines which fields are included in the alerts + connectors, + connectorTimeout, + datasetName, + esClient, + evaluationId, + evaluatorConnectorId, + langSmithApiKey, + langSmithProject, + logger, + runName, + size, +}: { + actionsClient: PublicMethodsOf<ActionsClient>; + attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; + alertsIndexPattern: string; + anonymizationFields?: AnonymizationFieldResponse[]; + connectors: Connector[]; + connectorTimeout: number; + datasetName: string; + esClient: ElasticsearchClient; + evaluationId: string; + evaluatorConnectorId: string | undefined; + langSmithApiKey: string | undefined; + langSmithProject: string | undefined; + logger: Logger; + runName: string; + size: number; +}): Promise<void> => { + await asyncForEach(attackDiscoveryGraphs, async ({ getDefaultAttackDiscoveryGraph }) => { + // create a graph for every connector: + const graphs: Array<{ + connector: Connector; + graph: DefaultAttackDiscoveryGraph; + llmType: string | undefined; + name: string; + traceOptions: { + projectName: string | undefined; + tracers: LangChainTracer[]; + }; + }> = connectors.map((connector) => { + const llmType = getLlmType(connector.actionTypeId); + + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: connector.id, + llmType, + logger, + temperature: 0, // zero temperature for attack discovery, because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + const graph = getDefaultAttackDiscoveryGraph({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + size, + }); + + return { + connector, + graph, + llmType, + name: `${runName} - ${connector.name} - ${evaluationId} - Attack discovery`, + traceOptions, + }; + }); + + // run the evaluations for each graph: + await runEvaluations({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + datasetName, + graphs, + langSmithApiKey, + logger, + }); + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts new file mode 100644 index 0000000000000..19eb99d57c84c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { Logger } from '@kbn/core/server'; +import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; +import { asyncForEach } from '@kbn/std'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Client } from 'langsmith'; +import { evaluate } from 'langsmith/evaluation'; + +import { getEvaluatorLlm } from '../helpers/get_evaluator_llm'; +import { getCustomEvaluator } from '../helpers/get_custom_evaluator'; +import { getDefaultPromptTemplate } from '../helpers/get_custom_evaluator/get_default_prompt_template'; +import { getGraphInputOverrides } from '../helpers/get_graph_input_overrides'; +import { DefaultAttackDiscoveryGraph } from '../../graphs/default_attack_discovery_graph'; +import { GraphState } from '../../graphs/default_attack_discovery_graph/types'; + +/** + * Runs an evaluation for each graph so they show up separately (resulting in + * each dataset run grouped by connector) + */ +export const runEvaluations = async ({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + datasetName, + graphs, + langSmithApiKey, + logger, +}: { + actionsClient: PublicMethodsOf<ActionsClient>; + connectorTimeout: number; + evaluatorConnectorId: string | undefined; + datasetName: string; + graphs: Array<{ + connector: Connector; + graph: DefaultAttackDiscoveryGraph; + llmType: string | undefined; + name: string; + traceOptions: { + projectName: string | undefined; + tracers: LangChainTracer[]; + }; + }>; + langSmithApiKey: string | undefined; + logger: Logger; +}): Promise<void> => + asyncForEach(graphs, async ({ connector, graph, llmType, name, traceOptions }) => { + const subject = `connector "${connector.name}" (${llmType}), running experiment "${name}"`; + + try { + logger.info( + () => + `Evaluating ${subject} with dataset "${datasetName}" and evaluator "${evaluatorConnectorId}"` + ); + + const predict = async (input: unknown): Promise<GraphState> => { + logger.debug(() => `Raw example Input for ${subject}":\n ${input}`); + + // The example `Input` may have overrides for the initial state of the graph: + const overrides = getGraphInputOverrides(input); + + return graph.invoke( + { + ...overrides, + }, + { + callbacks: [...(traceOptions.tracers ?? [])], + runName: name, + tags: ['evaluation', llmType ?? ''], + } + ); + }; + + const llm = await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector: connector, + langSmithApiKey, + logger, + }); + + const customEvaluator = getCustomEvaluator({ + criteria: 'correctness', + key: 'attack_discovery_correctness', + llm, + template: getDefaultPromptTemplate(), + }); + + const evalOutput = await evaluate(predict, { + client: new Client({ apiKey: langSmithApiKey }), + data: datasetName ?? '', + evaluators: [customEvaluator], + experimentPrefix: name, + maxConcurrency: 5, // prevents rate limiting + }); + + logger.info(() => `Evaluation complete for ${subject}`); + + logger.debug( + () => `Evaluation output for ${subject}:\n ${JSON.stringify(evalOutput, null, 2)}` + ); + } catch (e) { + logger.error(`Error evaluating ${subject}: ${e}`); + } + }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts new file mode 100644 index 0000000000000..fb5df8f26d0c2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// LangGraph metadata +export const ATTACK_DISCOVERY_GRAPH_RUN_NAME = 'Attack discovery'; +export const ATTACK_DISCOVERY_TAG = 'attack-discovery'; + +// Limits +export const DEFAULT_MAX_GENERATION_ATTEMPTS = 10; +export const DEFAULT_MAX_HALLUCINATION_FAILURES = 5; +export const DEFAULT_MAX_REPEATED_GENERATIONS = 3; + +export const NodeType = { + GENERATE_NODE: 'generate', + REFINE_NODE: 'refine', + RETRIEVE_ANONYMIZED_ALERTS_NODE: 'retrieve_anonymized_alerts', +} as const; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts new file mode 100644 index 0000000000000..225c4a2b8935c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGenerateOrEndDecision } from '.'; + +describe('getGenerateOrEndDecision', () => { + it('returns "end" when hasZeroAlerts is true', () => { + const result = getGenerateOrEndDecision(true); + + expect(result).toEqual('end'); + }); + + it('returns "generate" when hasZeroAlerts is false', () => { + const result = getGenerateOrEndDecision(false); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts new file mode 100644 index 0000000000000..b134b2f3a6118 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getGenerateOrEndDecision = (hasZeroAlerts: boolean): 'end' | 'generate' => + hasZeroAlerts ? 'end' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts new file mode 100644 index 0000000000000..06dd1529179fa --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; + +import { getGenerateOrEndEdge } from '.'; +import type { GraphState } from '../../types'; + +const logger = loggerMock.create(); + +const graphState: GraphState = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + combinedGenerations: 'generations', + combinedRefinements: 'refinements', + errors: [], + generationAttempts: 0, + generations: [], + hallucinationFailures: 0, + maxGenerationAttempts: 10, + maxHallucinationFailures: 5, + maxRepeatedGenerations: 10, + refinements: [], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: null, +}; + +describe('getGenerateOrEndEdge', () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns 'end' when there are zero alerts", () => { + const state: GraphState = { + ...graphState, + anonymizedAlerts: [], // <-- zero alerts + }; + + const edge = getGenerateOrEndEdge(logger); + const result = edge(state); + + expect(result).toEqual('end'); + }); + + it("returns 'generate' when there are alerts", () => { + const edge = getGenerateOrEndEdge(logger); + const result = edge(graphState); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts new file mode 100644 index 0000000000000..5bfc4912298eb --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import { getGenerateOrEndDecision } from './helpers/get_generate_or_end_decision'; +import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; +import type { GraphState } from '../../types'; + +export const getGenerateOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'generate' => { + logger?.debug(() => '---GENERATE OR END---'); + const { anonymizedAlerts } = state; + + const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); + + const decision = getGenerateOrEndDecision(hasZeroAlerts); + + logger?.debug( + () => `generatOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + hasZeroAlerts, + }, + null, + 2 + )} +\n---GENERATE OR END: ${decision}---` + ); + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts new file mode 100644 index 0000000000000..42c63b18459ed --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGenerateOrRefineOrEndDecision } from '.'; + +describe('getGenerateOrRefineOrEndDecision', () => { + it("returns 'end' if getShouldEnd returns true", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: false, + hasZeroAlerts: true, + maxHallucinationFailuresReached: true, + maxRetriesReached: true, + }); + + expect(result).toEqual('end'); + }); + + it("returns 'refine' if hasUnrefinedResults is true and getShouldEnd returns false", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: true, + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toEqual('refine'); + }); + + it("returns 'generate' if hasUnrefinedResults is false and getShouldEnd returns false", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: false, + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts new file mode 100644 index 0000000000000..b409f63f71a69 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getShouldEnd } from '../get_should_end'; + +export const getGenerateOrRefineOrEndDecision = ({ + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasUnrefinedResults: boolean; + hasZeroAlerts: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): 'end' | 'generate' | 'refine' => { + if (getShouldEnd({ hasZeroAlerts, maxHallucinationFailuresReached, maxRetriesReached })) { + return 'end'; + } else if (hasUnrefinedResults) { + return 'refine'; + } else { + return 'generate'; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts new file mode 100644 index 0000000000000..82480a6ad6889 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getShouldEnd } from '.'; + +describe('getShouldEnd', () => { + it('returns true if hasZeroAlerts is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: true, // <-- true + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toBe(true); + }); + + it('returns true if maxHallucinationFailuresReached is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: true, // <-- true + maxRetriesReached: false, + }); + + expect(result).toBe(true); + }); + + it('returns true if maxRetriesReached is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: true, // <-- true + }); + + expect(result).toBe(true); + }); + + it('returns false if all conditions are false', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toBe(false); + }); + + it('returns true if all conditions are true', () => { + const result = getShouldEnd({ + hasZeroAlerts: true, + maxHallucinationFailuresReached: true, + maxRetriesReached: true, + }); + + expect(result).toBe(true); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts new file mode 100644 index 0000000000000..9724ba25886fa --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getShouldEnd = ({ + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasZeroAlerts: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => hasZeroAlerts || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts new file mode 100644 index 0000000000000..585a1bc2dcac3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; + +import { getGenerateOrRefineOrEndEdge } from '.'; +import type { GraphState } from '../../types'; + +const logger = loggerMock.create(); + +const graphState: GraphState = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + combinedGenerations: '', + combinedRefinements: '', + errors: [], + generationAttempts: 0, + generations: [], + hallucinationFailures: 0, + maxGenerationAttempts: 10, + maxHallucinationFailures: 5, + maxRepeatedGenerations: 3, + refinements: [], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: null, +}; + +describe('getGenerateOrRefineOrEndEdge', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns "end" when there are zero alerts', () => { + const withZeroAlerts: GraphState = { + ...graphState, + anonymizedAlerts: [], // <-- zero alerts + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withZeroAlerts); + + expect(result).toEqual('end'); + }); + + it('returns "end" when max hallucination failures are reached', () => { + const withMaxHallucinationFailures: GraphState = { + ...graphState, + hallucinationFailures: 5, + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withMaxHallucinationFailures); + + expect(result).toEqual('end'); + }); + + it('returns "end" when max retries are reached', () => { + const withMaxRetries: GraphState = { + ...graphState, + generationAttempts: 10, + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withMaxRetries); + + expect(result).toEqual('end'); + }); + + it('returns refine when there are unrefined results', () => { + const withUnrefinedResults: GraphState = { + ...graphState, + unrefinedResults: [ + { + alertIds: [], + id: 'test-id', + detailsMarkdown: 'test-details', + entitySummaryMarkdown: 'test-summary', + summaryMarkdown: 'test-summary', + title: 'test-title', + timestamp: '2024-10-10T21:01:24.148Z', + }, + ], + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withUnrefinedResults); + + expect(result).toEqual('refine'); + }); + + it('return generate when there are no unrefined results', () => { + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(graphState); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts new file mode 100644 index 0000000000000..3368a04ec9204 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import { getGenerateOrRefineOrEndDecision } from './helpers/get_generate_or_refine_or_end_decision'; +import { getHasResults } from '../helpers/get_has_results'; +import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import type { GraphState } from '../../types'; + +export const getGenerateOrRefineOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'generate' | 'refine' => { + logger?.debug(() => '---GENERATE OR REFINE OR END---'); + const { + anonymizedAlerts, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + unrefinedResults, + } = state; + + const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); + const hasUnrefinedResults = getHasResults(unrefinedResults); + const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + const decision = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + logger?.debug( + () => + `generatOrRefineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + generationAttempts, + hallucinationFailures, + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, + unrefinedResults: unrefinedResults?.length ?? 0, + }, + null, + 2 + )} + \n---GENERATE OR REFINE OR END: ${decision}---` + ); + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts new file mode 100644 index 0000000000000..413f01b74dece --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +export const getHasResults = (attackDiscoveries: AttackDiscovery[] | null): boolean => + attackDiscoveries !== null; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts new file mode 100644 index 0000000000000..d768b363f101e --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Document } from '@langchain/core/documents'; +import { isEmpty } from 'lodash/fp'; + +export const getHasZeroAlerts = (anonymizedAlerts: Document[]): boolean => + isEmpty(anonymizedAlerts); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts new file mode 100644 index 0000000000000..7168aa08aeef2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getShouldEnd } from '../get_should_end'; + +export const getRefineOrEndDecision = ({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasFinalResults: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): 'refine' | 'end' => + getShouldEnd({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }) + ? 'end' + : 'refine'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts new file mode 100644 index 0000000000000..697f93dd3a02f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getShouldEnd = ({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasFinalResults: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => hasFinalResults || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts new file mode 100644 index 0000000000000..85140dceafdcb --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import { getRefineOrEndDecision } from './helpers/get_refine_or_end_decision'; +import { getHasResults } from '../helpers/get_has_results'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import type { GraphState } from '../../types'; + +export const getRefineOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'refine' => { + logger?.debug(() => '---REFINE OR END---'); + const { + attackDiscoveries, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + } = state; + + const hasFinalResults = getHasResults(attackDiscoveries); + const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + const decision = getRefineOrEndDecision({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + logger?.debug( + () => + `refineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + attackDiscoveries: attackDiscoveries?.length ?? 0, + generationAttempts, + hallucinationFailures, + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }, + null, + 2 + )} + \n---REFINE OR END: ${decision}---` + ); + + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts new file mode 100644 index 0000000000000..050ca17484185 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Document } from '@langchain/core/documents'; + +export const getRetrieveOrGenerate = ( + anonymizedAlerts: Document[] +): 'retrieve_anonymized_alerts' | 'generate' => + anonymizedAlerts.length === 0 ? 'retrieve_anonymized_alerts' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts new file mode 100644 index 0000000000000..ad0512497d07d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import { getRetrieveOrGenerate } from './get_retrieve_or_generate'; +import type { GraphState } from '../../types'; + +export const getRetrieveAnonymizedAlertsOrGenerateEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'retrieve_anonymized_alerts' | 'generate' => { + logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS OR GENERATE---'); + const { anonymizedAlerts } = state; + + const decision = getRetrieveOrGenerate(anonymizedAlerts); + + logger?.debug( + () => + `retrieveAnonymizedAlertsOrGenerateEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + }, + null, + 2 + )} + \n---RETRIEVE ANONYMIZED ALERTS OR GENERATE: ${decision}---` + ); + + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts new file mode 100644 index 0000000000000..07985381afa73 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getMaxHallucinationFailuresReached = ({ + hallucinationFailures, + maxHallucinationFailures, +}: { + hallucinationFailures: number; + maxHallucinationFailures: number; +}): boolean => hallucinationFailures >= maxHallucinationFailures; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts new file mode 100644 index 0000000000000..c1e36917b45cf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getMaxRetriesReached = ({ + generationAttempts, + maxGenerationAttempts, +}: { + generationAttempts: number; + maxGenerationAttempts: number; +}): boolean => generationAttempts >= maxGenerationAttempts; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts new file mode 100644 index 0000000000000..b2c90636ef523 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import type { CompiledStateGraph } from '@langchain/langgraph'; +import { END, START, StateGraph } from '@langchain/langgraph'; + +import { NodeType } from './constants'; +import { getGenerateOrEndEdge } from './edges/generate_or_end'; +import { getGenerateOrRefineOrEndEdge } from './edges/generate_or_refine_or_end'; +import { getRefineOrEndEdge } from './edges/refine_or_end'; +import { getRetrieveAnonymizedAlertsOrGenerateEdge } from './edges/retrieve_anonymized_alerts_or_generate'; +import { getDefaultGraphState } from './state'; +import { getGenerateNode } from './nodes/generate'; +import { getRefineNode } from './nodes/refine'; +import { getRetrieveAnonymizedAlertsNode } from './nodes/retriever'; +import type { GraphState } from './types'; + +export interface GetDefaultAttackDiscoveryGraphParams { + alertsIndexPattern?: string; + anonymizationFields: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + llm: ActionsClientLlm; + logger?: Logger; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; + size: number; +} + +export type DefaultAttackDiscoveryGraph = ReturnType<typeof getDefaultAttackDiscoveryGraph>; + +/** + * This function returns a compiled state graph that represents the default + * Attack discovery graph. + * + * Refer to the following diagram for this graph: + * x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png + */ +export const getDefaultAttackDiscoveryGraph = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + onNewReplacements, + replacements, + size, +}: GetDefaultAttackDiscoveryGraphParams): CompiledStateGraph< + GraphState, + Partial<GraphState>, + 'generate' | 'refine' | 'retrieve_anonymized_alerts' | '__start__' +> => { + try { + const graphState = getDefaultGraphState(); + + // get nodes: + const retrieveAnonymizedAlertsNode = getRetrieveAnonymizedAlertsNode({ + alertsIndexPattern, + anonymizationFields, + esClient, + logger, + onNewReplacements, + replacements, + size, + }); + + const generateNode = getGenerateNode({ + llm, + logger, + }); + + const refineNode = getRefineNode({ + llm, + logger, + }); + + // get edges: + const generateOrEndEdge = getGenerateOrEndEdge(logger); + + const generatOrRefineOrEndEdge = getGenerateOrRefineOrEndEdge(logger); + + const refineOrEndEdge = getRefineOrEndEdge(logger); + + const retrieveAnonymizedAlertsOrGenerateEdge = + getRetrieveAnonymizedAlertsOrGenerateEdge(logger); + + // create the graph: + const graph = new StateGraph<GraphState>({ channels: graphState }) + .addNode(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, retrieveAnonymizedAlertsNode) + .addNode(NodeType.GENERATE_NODE, generateNode) + .addNode(NodeType.REFINE_NODE, refineNode) + .addConditionalEdges(START, retrieveAnonymizedAlertsOrGenerateEdge, { + generate: NodeType.GENERATE_NODE, + retrieve_anonymized_alerts: NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, + }) + .addConditionalEdges(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, generateOrEndEdge, { + end: END, + generate: NodeType.GENERATE_NODE, + }) + .addConditionalEdges(NodeType.GENERATE_NODE, generatOrRefineOrEndEdge, { + end: END, + generate: NodeType.GENERATE_NODE, + refine: NodeType.REFINE_NODE, + }) + .addConditionalEdges(NodeType.REFINE_NODE, refineOrEndEdge, { + end: END, + refine: NodeType.REFINE_NODE, + }); + + // compile the graph: + return graph.compile(); + } catch (e) { + throw new Error(`Unable to compile AttackDiscoveryGraph\n${e}`); + } +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts similarity index 100% rename from x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts new file mode 100644 index 0000000000000..ed5549acc586a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockEmptyOpenAndAcknowledgedAlertsQueryResults = { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts new file mode 100644 index 0000000000000..3f22f787f54f8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts @@ -0,0 +1,1396 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockOpenAndAcknowledgedAlertsQueryResults = { + took: 13, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 31, + relation: 'eq', + }, + max_score: null, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', + ], + 'process.parent.name': ['unix1'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', + ], + 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], + 'process.pid': [1227], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/Users/james/unix1'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': ['/Users/james/unix1'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/Users/james/unix1', + '/Users/james/library/Keychains/login.keychain-db', + 'TempTemp1234!!', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [3], + 'process.name': ['unix1'], + 'process.parent.args': [ + '/Users/james/unix1', + '/Users/james/library/Keychains/login.keychain-db', + 'TempTemp1234!!', + ], + '@timestamp': ['2024-05-07T12:48:45.032Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', + ], + 'host.risk.calculated_level': ['High'], + _id: ['b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560'], + 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:39.368Z'], + }, + sort: [99, 1715086125032], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1220], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.030Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'host.risk.calculated_level': ['High'], + _id: ['0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:38.061Z'], + }, + sort: [99, 1715086125030], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', + ], + 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], + 'process.pid': [1220], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/Users/james/unix1'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['unix1'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.029Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'host.risk.calculated_level': ['High'], + _id: ['600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a'], + 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:37.881Z'], + }, + sort: [99, 1715086125029], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['3f19892ab44eb9bc7bc03f438944301e'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'f80234ff6fed2c62d23f37443f2412fbe806711b6add2ac126e03e282082c8f5', + ], + 'process.code_signature.signing_id': ['com.apple.chmod'], + 'process.pid': [1219], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Software Signing'], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/bin/chmod'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': ['chmod', '777', '/Users/james/unix1'], + 'process.code_signature.status': ['No error.'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['chmod'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.028Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['chmod 777 /Users/james/unix1'], + 'host.risk.calculated_level': ['High'], + _id: ['e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c'], + 'process.hash.sha1': ['217490d4f51717aa3b301abec96be08602370d2d'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:37.869Z'], + }, + sort: [99, 1715086125028], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['643dddff1a57cbf70594854b44eb1a1d'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [73.02488], + 'rule.reference': [ + 'https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/collection/osx/prompt.py', + 'https://ss64.com/osx/osascript.html', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'bab17feba710b469e5d96820f0cb7ed511d983e5817f374ec3cb46462ac5b794', + ], + 'process.pid': [1206], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Software Signing'], + 'host.os.version': ['13.4'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': [ + 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', + ], + 'host.name': ['SRVMAC08'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'group.name': ['staff'], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['Potential Credentials Phishing via OSASCRIPT'], + 'threat.tactic.id': ['TA0006'], + 'threat.tactic.name': ['Credential Access'], + 'threat.technique.id': ['T1056'], + 'process.parent.args_count': [0], + 'threat.technique.subtechnique.reference': [ + 'https://attack.mitre.org/techniques/T1056/002/', + ], + 'process.name': ['osascript'], + 'threat.technique.subtechnique.name': ['GUI Input Capture'], + 'process.parent.code_signature.trusted': [false], + _id: ['2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f'], + 'threat.technique.name': ['Input Capture'], + 'group.id': ['20'], + 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0006/'], + 'user.name': ['james'], + 'threat.framework': ['MITRE ATT&CK'], + 'process.code_signature.signing_id': ['com.apple.osascript'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.executable': ['/usr/bin/osascript'], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.args': [ + 'osascript', + '-e', + 'display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', + ], + 'process.code_signature.status': ['No error.'], + message: [ + 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', + ], + '@timestamp': ['2024-05-07T12:48:45.027Z'], + 'threat.technique.subtechnique.id': ['T1056.002'], + 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1056/'], + 'process.command_line': [ + 'osascript -e display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', + ], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['0568baae15c752208ae56d8f9c737976d6de2e3a'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:09.909Z'], + }, + sort: [99, 1715086125027], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1200], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.023Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:06.888Z'], + }, + sort: [99, 1715086125023], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1169], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.022Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:27:47.362Z'], + }, + sort: [99, 1715086125022], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1123], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.020Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:25:24.716Z'], + }, + sort: [99, 1715086125020], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.017Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.051Z'], + }, + sort: [99, 1715086125017], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Windows\\mpsvc.dll'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'library'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['mpsvc.dll'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.008Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:18.093Z'], + }, + sort: [99, 1715086125008], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Windows\\mpsvc.dll'], + 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], + 'process.parent.name': ['explorer.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.pid': [1008], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\explorer.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['mpsvc.dll'], + 'process.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.code_signature.status': ['errorExpired'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], + '@timestamp': ['2024-05-07T12:48:45.007Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'host.risk.calculated_level': ['High'], + _id: ['dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515'], + 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:17.887Z'], + }, + sort: [99, 1715086125007], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], + 'process.parent.name': ['explorer.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.pid': [1008], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\explorer.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.code_signature.status': ['errorExpired'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], + '@timestamp': ['2024-05-07T12:48:45.006Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'host.risk.calculated_level': ['High'], + _id: ['f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a'], + 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:17.544Z'], + }, + sort: [99, 1715086125006], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [''], + 'process.parent.name': ['userinit.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', + ], + 'process.pid': [6228], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'process.pe.original_file_name': ['EXPLORER.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\explorer.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.args': ['C:\\Windows\\Explorer.EXE'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.name': ['explorer.exe'], + '@timestamp': ['2024-05-07T12:48:45.004Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': ['C:\\Windows\\Explorer.EXE'], + 'host.risk.calculated_level': ['High'], + _id: ['6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66'], + 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:37:18.152Z'], + }, + sort: [99, 1715086125004], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [''], + 'process.parent.name': ['userinit.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', + ], + 'process.pid': [6228], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'process.pe.original_file_name': ['EXPLORER.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\explorer.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'process.args': ['C:\\Windows\\Explorer.EXE'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.name': ['explorer.exe'], + '@timestamp': ['2024-05-07T12:48:45.001Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': ['C:\\Windows\\Explorer.EXE'], + 'host.risk.calculated_level': ['High'], + _id: ['ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316'], + 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:36:43.813Z'], + }, + sort: [99, 1715086125001], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'process', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Ransomware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'Ransomware.files.data': [ + '2D002D002D003D003D003D0020005700', + '2D002D002D003D003D003D0020005700', + '2D002D002D003D003D003D0020005700', + ], + 'process.code_signature.trusted': [true], + 'Ransomware.files.metrics': ['CANARY_ACTIVITY'], + 'kibana.alert.workflow_status': ['open'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'Ransomware.files.score': [0, 0, 0], + 'process.parent.code_signature.trusted': [false], + _id: ['0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f'], + 'Ransomware.version': ['1.6.0'], + 'user.name': ['Administrator'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'Ransomware.files.operation': ['creation', 'creation', 'creation'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.Ext.token.integrity_level_name': ['high'], + 'Ransomware.files.path': [ + 'c:\\hd3vuk19y-readme.txt', + 'c:\\$winreagent\\hd3vuk19y-readme.txt', + 'c:\\aaantiransomelastic-do-not-touch-dab6d40c-a6a1-442c-adc4-9d57a47e58d7\\hd3vuk19y-readme.txt', + ], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'Ransomware.files.entropy': [3.629971457026797, 3.629971457026797, 3.629971457026797], + 'Ransomware.feature': ['canary'], + 'Ransomware.files.extension': ['txt', 'txt', 'txt'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Ransomware Detection Alert'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.000Z'], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.964Z'], + }, + sort: [99, 1715086125000], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.996Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.174Z'], + }, + sort: [99, 1715086124996], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.986Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.066Z'], + }, + sort: [99, 1715086124986], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.Ext.memory_region.malware_signature.primary.matches': [ + 'WVmF9nQli1UIg2YEAIk+iwoLSgQ=', + 'dQxy0zPAQF9eW4vlXcMzwOv1VYvsgw==', + 'DIsEsIN4BAV1HP9wCP9wDP91DP8=', + '+4tF/FCLCP9RCF6Lx19bi+Vdw1U=', + 'vAAAADPSi030i/GLRfAPpMEBwe4f', + 'VIvO99GLwiNN3PfQM030I8czReiJ', + 'DIlGDIXAdSozwOtsi0YIhcB0Yms=', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': [ + 'Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi', + ], + 'host.name': ['SRVWIN02'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['Windows.Ransomware.Sodinokibi'], + 'process.parent.args_count': [1], + 'process.Ext.memory_region.bytes_compressed_present': [false], + 'process.name': ['MsMpEng.exe'], + 'process.parent.code_signature.trusted': [false], + _id: ['ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e'], + 'user.name': ['Administrator'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.Ext.memory_region.malware_signature.all_names': [ + 'Windows.Ransomware.Sodinokibi', + ], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.Ext.memory_region.malware_signature.primary.signature.name': [ + 'Windows.Ransomware.Sodinokibi', + ], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.975Z'], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:25.169Z'], + }, + sort: [99, 1715086124975], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll'], + 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], + 'event.category': ['malware', 'intrusion_detection', 'library'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], + 'process.parent.name': ['Q3C7N1V8.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', + ], + 'process.pid': [7824], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [false], + 'process.pe.original_file_name': ['RUNDLL32.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['12e6642cf6413bdf5388bee663080fa299591b2ba023d069286f3be9647547c8'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN01'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['cdnver.dll'], + 'process.args': [ + 'C:\\Windows\\System32\\rundll32.exe', + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', + ], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['rundll32.exe'], + 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], + '@timestamp': ['2024-05-07T12:47:32.838Z'], + 'process.command_line': [ + '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', + ], + 'host.risk.calculated_level': ['High'], + _id: ['cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b'], + 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-16T01:51:26.472Z'], + }, + sort: [99, 1715086052838], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], + 'process.parent.name': ['Q3C7N1V8.exe'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', + ], + 'process.pid': [7824], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': [ + 'Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments', + ], + 'host.name': ['SRVWIN01'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['RunDLL32 with Unusual Arguments'], + 'threat.tactic.id': ['TA0005'], + 'threat.tactic.name': ['Defense Evasion'], + 'threat.technique.id': ['T1218'], + 'process.parent.args_count': [1], + 'threat.technique.subtechnique.reference': [ + 'https://attack.mitre.org/techniques/T1218/011/', + ], + 'process.name': ['rundll32.exe'], + 'threat.technique.subtechnique.name': ['Rundll32'], + _id: ['6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3'], + 'threat.technique.name': ['System Binary Proxy Execution'], + 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0005/'], + 'user.name': ['Administrator'], + 'threat.framework': ['MITRE ATT&CK'], + 'process.working_directory': ['C:\\Users\\Administrator\\Documents\\'], + 'process.pe.original_file_name': ['RUNDLL32.EXE'], + 'event.module': ['endpoint'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], + 'process.args': [ + 'C:\\Windows\\System32\\rundll32.exe', + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', + ], + 'process.code_signature.status': ['trusted'], + message: ['Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments'], + 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], + '@timestamp': ['2024-05-07T12:47:32.836Z'], + 'threat.technique.subtechnique.id': ['T1218.011'], + 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1218/'], + 'process.command_line': [ + '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', + ], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-16T01:51:26.348Z'], + }, + sort: [99, 1715086052836], + }, + ], + }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts new file mode 100644 index 0000000000000..a40dde44f8d67 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GraphState } from '../../../../types'; + +export const discardPreviousGenerations = ({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected, + state, +}: { + generationAttempts: number; + hallucinationFailures: number; + isHallucinationDetected: boolean; + state: GraphState; +}): GraphState => { + return { + ...state, + combinedGenerations: '', // <-- reset the combined generations + generationAttempts: generationAttempts + 1, + generations: [], // <-- reset the generations + hallucinationFailures: isHallucinationDetected + ? hallucinationFailures + 1 + : hallucinationFailures, + }; +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts similarity index 70% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts index bc290bf172382..287f5e6b2130a 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts @@ -4,15 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; -describe('getAttackDiscoveryPrompt', () => { - it('should generate the correct attack discovery prompt', () => { +import { getAlertsContextPrompt } from '.'; +import { getDefaultAttackDiscoveryPrompt } from '../../../helpers/get_default_attack_discovery_prompt'; + +describe('getAlertsContextPrompt', () => { + it('generates the correct prompt', () => { const anonymizedAlerts = ['Alert 1', 'Alert 2', 'Alert 3']; - const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. + const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds). -Use context from the following open and acknowledged alerts to provide insights: +Use context from the following alerts to provide insights: """ Alert 1 @@ -23,7 +25,10 @@ Alert 3 """ `; - const prompt = getAttackDiscoveryPrompt({ anonymizedAlerts }); + const prompt = getAlertsContextPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt: getDefaultAttackDiscoveryPrompt(), + }); expect(prompt).toEqual(expected); }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts new file mode 100644 index 0000000000000..d92d935053577 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. +export const getAlertsContextPrompt = ({ + anonymizedAlerts, + attackDiscoveryPrompt, +}: { + anonymizedAlerts: string[]; + attackDiscoveryPrompt: string; +}) => `${attackDiscoveryPrompt} + +Use context from the following alerts to provide insights: + +""" +${anonymizedAlerts.join('\n\n')} +""" +`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts new file mode 100644 index 0000000000000..fb7cf6bd59f98 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GraphState } from '../../../../types'; + +export const getAnonymizedAlertsFromState = (state: GraphState): string[] => + state.anonymizedAlerts.map((doc) => doc.pageContent); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts new file mode 100644 index 0000000000000..face2a6afc6bc --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +import { getMaxRetriesReached } from '../../../../helpers/get_max_retries_reached'; + +export const getUseUnrefinedResults = ({ + generationAttempts, + maxGenerationAttempts, + unrefinedResults, +}: { + generationAttempts: number; + maxGenerationAttempts: number; + unrefinedResults: AttackDiscovery[] | null; +}): boolean => { + const nextAttemptWouldExcedLimit = getMaxRetriesReached({ + generationAttempts: generationAttempts + 1, // + 1, because we just used an attempt + maxGenerationAttempts, + }); + + return nextAttemptWouldExcedLimit && unrefinedResults != null && unrefinedResults.length > 0; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts new file mode 100644 index 0000000000000..1fcd81622f0fe --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import type { Logger } from '@kbn/core/server'; + +import { discardPreviousGenerations } from './helpers/discard_previous_generations'; +import { extractJson } from '../helpers/extract_json'; +import { getAnonymizedAlertsFromState } from './helpers/get_anonymized_alerts_from_state'; +import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; +import { getCombined } from '../helpers/get_combined'; +import { getCombinedAttackDiscoveryPrompt } from '../helpers/get_combined_attack_discovery_prompt'; +import { generationsAreRepeating } from '../helpers/generations_are_repeating'; +import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; +import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; +import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; +import type { GraphState } from '../../types'; + +export const getGenerateNode = ({ + llm, + logger, +}: { + llm: ActionsClientLlm; + logger?: Logger; +}): ((state: GraphState) => Promise<GraphState>) => { + const generate = async (state: GraphState): Promise<GraphState> => { + logger?.debug(() => `---GENERATE---`); + + const anonymizedAlerts: string[] = getAnonymizedAlertsFromState(state); + + const { + attackDiscoveryPrompt, + combinedGenerations, + generationAttempts, + generations, + hallucinationFailures, + maxGenerationAttempts, + maxRepeatedGenerations, + } = state; + + let combinedResponse = ''; // mutable, because it must be accessed in the catch block + let partialResponse = ''; // mutable, because it must be accessed in the catch block + + try { + const query = getCombinedAttackDiscoveryPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt, + combinedMaybePartialResults: combinedGenerations, + }); + + const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); + + logger?.debug( + () => `generate node is invoking the chain (${llmType}), attempt ${generationAttempts}` + ); + + const rawResponse = (await chain.invoke({ + format_instructions: formatInstructions, + query, + })) as unknown as string; + + // LOCAL MUTATION: + partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` + + // if the response is hallucinated, discard previous generations and start over: + if (responseIsHallucinated(partialResponse)) { + logger?.debug( + () => + `generate node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated generations and starting over` + ); + + return discardPreviousGenerations({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: true, + state, + }); + } + + // if the generations are repeating, discard previous generations and start over: + if ( + generationsAreRepeating({ + currentGeneration: partialResponse, + previousGenerations: generations, + sampleLastNGenerations: maxRepeatedGenerations, + }) + ) { + logger?.debug( + () => + `generate node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` + ); + + // discard the accumulated results and start over: + return discardPreviousGenerations({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: false, + state, + }); + } + + // LOCAL MUTATION: + combinedResponse = getCombined({ combinedGenerations, partialResponse }); // combine the new response with the previous ones + + const unrefinedResults = parseCombinedOrThrow({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName: 'generate', + }); + + // use the unrefined results if we already reached the max number of retries: + const useUnrefinedResults = getUseUnrefinedResults({ + generationAttempts, + maxGenerationAttempts, + unrefinedResults, + }); + + if (useUnrefinedResults) { + logger?.debug( + () => + `generate node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` + ); + } + + return { + ...state, + attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, // optionally skip the refinement step by returning the final answer + combinedGenerations: combinedResponse, + generationAttempts: generationAttempts + 1, + generations: [...generations, partialResponse], + unrefinedResults, + }; + } catch (error) { + const parsingError = `generate node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; + logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response + + return { + ...state, + combinedGenerations: combinedResponse, + errors: [...state.errors, parsingError], + generationAttempts: generationAttempts + 1, + generations: [...generations, partialResponse], + }; + } + }; + + return generate; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts new file mode 100644 index 0000000000000..05210799f151c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; + +export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; +const GOOD_SYNTAX_EXAMPLES = + 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; + +const BAD_SYNTAX_EXAMPLES = + 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; + +const RECONNAISSANCE = 'Reconnaissance'; +const INITIAL_ACCESS = 'Initial Access'; +const EXECUTION = 'Execution'; +const PERSISTENCE = 'Persistence'; +const PRIVILEGE_ESCALATION = 'Privilege Escalation'; +const DISCOVERY = 'Discovery'; +const LATERAL_MOVEMENT = 'Lateral Movement'; +const COMMAND_AND_CONTROL = 'Command and Control'; +const EXFILTRATION = 'Exfiltration'; + +const MITRE_ATTACK_TACTICS = [ + RECONNAISSANCE, + INITIAL_ACCESS, + EXECUTION, + PERSISTENCE, + PRIVILEGE_ESCALATION, + DISCOVERY, + LATERAL_MOVEMENT, + COMMAND_AND_CONTROL, + EXFILTRATION, +] as const; + +export const AttackDiscoveriesGenerationSchema = z.object({ + insights: z + .array( + z.object({ + alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), + detailsMarkdown: z + .string() + .describe( + `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), + entitySummaryMarkdown: z + .string() + .optional() + .describe( + `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` + ), + mitreAttackTactics: z + .string() + .array() + .optional() + .describe( + `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( + ',' + )}` + ), + summaryMarkdown: z + .string() + .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), + title: z + .string() + .describe( + 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' + ), + }) + ) + .describe( + `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts new file mode 100644 index 0000000000000..fd824709f5fcf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const addTrailingBackticksIfNecessary = (text: string): string => { + const leadingJSONpattern = /^\w*```json(.*?)/s; + const trailingBackticksPattern = /(.*?)```\w*$/s; + + const hasLeadingJSONWrapper = leadingJSONpattern.test(text); + const hasTrailingBackticks = trailingBackticksPattern.test(text); + + if (hasLeadingJSONWrapper && !hasTrailingBackticks) { + return `${text}\n\`\`\``; + } + + return text; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts new file mode 100644 index 0000000000000..5e13ec9f0dafe --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extractJson } from '.'; + +describe('extractJson', () => { + it('returns the JSON text surrounded by ```json and ``` with no whitespace or additional text', () => { + const input = '```json{"key": "value"}```'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('returns the JSON block when surrounded by additional text and whitespace', () => { + const input = + 'You asked for some JSON, here it is:\n```json\n{"key": "value"}\n```\nI hope that works for you.'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('returns the original text if no JSON block is found', () => { + const input = "There's no JSON here, just some text."; + + expect(extractJson(input)).toBe(input); + }); + + it('trims leading and trailing whitespace from the extracted JSON', () => { + const input = 'Text before\n```json\n {"key": "value"} \n```\nText after'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('handles incomplete JSON blocks with no trailing ```', () => { + const input = 'Text before\n```json\n{"key": "value"'; // <-- no closing ```, because incomplete generation + + expect(extractJson(input)).toBe('{"key": "value"'); + }); + + it('handles multiline json (real world edge case)', () => { + const input = + '```json\n{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}\n```'; + + const expected = + '{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('handles "Here is my analysis of the security events in JSON format" (real world edge case)', () => { + const input = + 'Here is my analysis of the security events in JSON format:\n\n```json\n{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; + + const expected = + '{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; + + expect(extractJson(input)).toBe(expected); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts new file mode 100644 index 0000000000000..79d3f9c0d0599 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const extractJson = (input: string): string => { + const regex = /```json\s*([\s\S]*?)(?:\s*```|$)/; + const match = input.match(regex); + + if (match && match[1]) { + return match[1].trim(); + } + + return input; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx new file mode 100644 index 0000000000000..7d6db4dd72dfd --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { generationsAreRepeating } from '.'; + +describe('getIsGenerationRepeating', () => { + it('returns true when all previous generations are the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen1', 'gen1'], // <-- all the same, length 3 + sampleLastNGenerations: 3, + }); + + expect(result).toBe(true); + }); + + it('returns false when some of the previous generations are NOT the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen2', 'gen1'], // <-- some are different, length 3 + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns true when all *sampled* generations are the same as the current generation, and there are older samples past the last N', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [ + 'gen2', // <-- older sample will be ignored + 'gen1', + 'gen1', + 'gen1', + ], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(true); + }); + + it('returns false when some of the *sampled* generations are NOT the same as the current generation, and there are additional samples past the last N', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [ + 'gen1', // <-- older sample will be ignored + 'gen1', + 'gen1', + 'gen2', + ], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when sampling fewer generations than sampleLastNGenerations, and all are the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen1'], // <-- same, but only 2 generations + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when sampling fewer generations than sampleLastNGenerations, and some are different from the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen2'], // <-- different, but only 2 generations + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when there are no previous generations to sample', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx new file mode 100644 index 0000000000000..6cc9cd86c9d2f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** Returns true if the last n generations are repeating the same output */ +export const generationsAreRepeating = ({ + currentGeneration, + previousGenerations, + sampleLastNGenerations, +}: { + currentGeneration: string; + previousGenerations: string[]; + sampleLastNGenerations: number; +}): boolean => { + const generationsToSample = previousGenerations.slice(-sampleLastNGenerations); + + if (generationsToSample.length < sampleLastNGenerations) { + return false; // Not enough generations to sample + } + + return generationsToSample.every((generation) => generation === currentGeneration); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts new file mode 100644 index 0000000000000..7eacaad1d7e39 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { Runnable } from '@langchain/core/runnables'; + +import { getOutputParser } from '../get_output_parser'; + +interface GetChainWithFormatInstructions { + chain: Runnable; + formatInstructions: string; + llmType: string; +} + +export const getChainWithFormatInstructions = ( + llm: ActionsClientLlm +): GetChainWithFormatInstructions => { + const outputParser = getOutputParser(); + const formatInstructions = outputParser.getFormatInstructions(); + + const prompt = ChatPromptTemplate.fromTemplate( + `Answer the user's question as best you can:\n{format_instructions}\n{query}` + ); + + const chain = prompt.pipe(llm); + const llmType = llm._llmType(); + + return { chain, formatInstructions, llmType }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts new file mode 100644 index 0000000000000..10b5c323891a1 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getCombined = ({ + combinedGenerations, + partialResponse, +}: { + combinedGenerations: string; + partialResponse: string; +}): string => `${combinedGenerations}${partialResponse}`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts new file mode 100644 index 0000000000000..4c9ac71f8310c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; + +import { getAlertsContextPrompt } from '../../generate/helpers/get_alerts_context_prompt'; +import { getContinuePrompt } from '../get_continue_prompt'; + +/** + * Returns the the initial query, or the initial query combined with a + * continuation prompt and partial results + */ +export const getCombinedAttackDiscoveryPrompt = ({ + anonymizedAlerts, + attackDiscoveryPrompt, + combinedMaybePartialResults, +}: { + anonymizedAlerts: string[]; + attackDiscoveryPrompt: string; + /** combined results that may contain incomplete JSON */ + combinedMaybePartialResults: string; +}): string => { + const alertsContextPrompt = getAlertsContextPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt, + }); + + return isEmpty(combinedMaybePartialResults) + ? alertsContextPrompt // no partial results yet + : `${alertsContextPrompt} + +${getContinuePrompt()} + +""" +${combinedMaybePartialResults} +""" + +`; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts new file mode 100644 index 0000000000000..628ba0531332c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getContinuePrompt = + (): string => `Continue exactly where you left off in the JSON output below, generating only the additional JSON output when it's required to complete your work. The additional JSON output MUST ALWAYS follow these rules: +1) it MUST conform to the schema above, because it will be checked against the JSON schema +2) it MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds), because it will be parsed as JSON +3) it MUST NOT repeat any the previous output, because that would prevent partial results from being combined +4) it MUST NOT restart from the beginning, because that would prevent partial results from being combined +5) it MUST NOT be prefixed or suffixed with additional text outside of the JSON, because that would prevent it from being combined and parsed as JSON: +`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts new file mode 100644 index 0000000000000..25bace13d40c8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultAttackDiscoveryPrompt = (): string => + "You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds)."; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts new file mode 100644 index 0000000000000..569c8cf4e04a5 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getOutputParser } from '.'; + +describe('getOutputParser', () => { + it('returns a structured output parser with the expected format instructions', () => { + const outputParser = getOutputParser(); + + const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. + +\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. + +For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} +would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. +Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. + +Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! + +Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: +\`\`\`json +{"type":"object","properties":{"insights":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"}},\"required\":[\"insights\"],\"additionalProperties":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"} +\`\`\` +`; + + expect(outputParser.getFormatInstructions()).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts new file mode 100644 index 0000000000000..2ca0d72b63eb4 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StructuredOutputParser } from 'langchain/output_parsers'; + +import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; + +export const getOutputParser = () => + StructuredOutputParser.fromZodSchema(AttackDiscoveriesGenerationSchema); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts new file mode 100644 index 0000000000000..3f7a0a9d802b3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +import { addTrailingBackticksIfNecessary } from '../add_trailing_backticks_if_necessary'; +import { extractJson } from '../extract_json'; +import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; + +export const parseCombinedOrThrow = ({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName, +}: { + /** combined responses that maybe valid JSON */ + combinedResponse: string; + generationAttempts: number; + nodeName: string; + llmType: string; + logger?: Logger; +}): AttackDiscovery[] => { + const timestamp = new Date().toISOString(); + + const extractedJson = extractJson(addTrailingBackticksIfNecessary(combinedResponse)); + + logger?.debug( + () => + `${nodeName} node is parsing extractedJson (${llmType}) from attempt ${generationAttempts}` + ); + + const unvalidatedParsed = JSON.parse(extractedJson); + + logger?.debug( + () => + `${nodeName} node is validating combined response (${llmType}) from attempt ${generationAttempts}` + ); + + const validatedResponse = AttackDiscoveriesGenerationSchema.parse(unvalidatedParsed); + + logger?.debug( + () => + `${nodeName} node successfully validated Attack discoveries response (${llmType}) from attempt ${generationAttempts}` + ); + + return [...validatedResponse.insights.map((insight) => ({ ...insight, timestamp }))]; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts new file mode 100644 index 0000000000000..f938f6436db98 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const responseIsHallucinated = (result: string): boolean => + result.includes('{{ host.name hostNameValue }}'); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts new file mode 100644 index 0000000000000..e642e598e73f0 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GraphState } from '../../../../types'; + +export const discardPreviousRefinements = ({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected, + state, +}: { + generationAttempts: number; + hallucinationFailures: number; + isHallucinationDetected: boolean; + state: GraphState; +}): GraphState => { + return { + ...state, + combinedRefinements: '', // <-- reset the combined refinements + generationAttempts: generationAttempts + 1, + refinements: [], // <-- reset the refinements + hallucinationFailures: isHallucinationDetected + ? hallucinationFailures + 1 + : hallucinationFailures, + }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts new file mode 100644 index 0000000000000..11ea40a48ae55 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; +import { isEmpty } from 'lodash/fp'; + +import { getContinuePrompt } from '../../../helpers/get_continue_prompt'; + +/** + * Returns a prompt that combines the initial query, a refine prompt, and partial results + */ +export const getCombinedRefinePrompt = ({ + attackDiscoveryPrompt, + combinedRefinements, + refinePrompt, + unrefinedResults, +}: { + attackDiscoveryPrompt: string; + combinedRefinements: string; + refinePrompt: string; + unrefinedResults: AttackDiscovery[] | null; +}): string => { + const baseQuery = `${attackDiscoveryPrompt} + +${refinePrompt} + +""" +${JSON.stringify(unrefinedResults, null, 2)} +""" + +`; + + return isEmpty(combinedRefinements) + ? baseQuery // no partial results yet + : `${baseQuery} + +${getContinuePrompt()} + +""" +${combinedRefinements} +""" + +`; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts new file mode 100644 index 0000000000000..5743316669785 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultRefinePrompt = + (): string => `You previously generated the following insights, but sometimes they represent the same attack. + +Combine the insights below, when they represent the same attack; leave any insights that are not combined unchanged:`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts new file mode 100644 index 0000000000000..13d0a2228a3ee --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Note: the conditions tested here are different than the generate node + */ +export const getUseUnrefinedResults = ({ + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => maxRetriesReached || maxHallucinationFailuresReached; // we may have reached max halucination failures, but we still want to use the unrefined results diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts new file mode 100644 index 0000000000000..0c7987eef92bc --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import type { Logger } from '@kbn/core/server'; + +import { discardPreviousRefinements } from './helpers/discard_previous_refinements'; +import { extractJson } from '../helpers/extract_json'; +import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; +import { getCombined } from '../helpers/get_combined'; +import { getCombinedRefinePrompt } from './helpers/get_combined_refine_prompt'; +import { generationsAreRepeating } from '../helpers/generations_are_repeating'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; +import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; +import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; +import type { GraphState } from '../../types'; + +export const getRefineNode = ({ + llm, + logger, +}: { + llm: ActionsClientLlm; + logger?: Logger; +}): ((state: GraphState) => Promise<GraphState>) => { + const refine = async (state: GraphState): Promise<GraphState> => { + logger?.debug(() => '---REFINE---'); + + const { + attackDiscoveryPrompt, + combinedRefinements, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + maxRepeatedGenerations, + refinements, + refinePrompt, + unrefinedResults, + } = state; + + let combinedResponse = ''; // mutable, because it must be accessed in the catch block + let partialResponse = ''; // mutable, because it must be accessed in the catch block + + try { + const query = getCombinedRefinePrompt({ + attackDiscoveryPrompt, + combinedRefinements, + refinePrompt, + unrefinedResults, + }); + + const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); + + logger?.debug( + () => `refine node is invoking the chain (${llmType}), attempt ${generationAttempts}` + ); + + const rawResponse = (await chain.invoke({ + format_instructions: formatInstructions, + query, + })) as unknown as string; + + // LOCAL MUTATION: + partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` + + // if the response is hallucinated, discard it: + if (responseIsHallucinated(partialResponse)) { + logger?.debug( + () => + `refine node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated refinements and starting over` + ); + + return discardPreviousRefinements({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: true, + state, + }); + } + + // if the refinements are repeating, discard previous refinements and start over: + if ( + generationsAreRepeating({ + currentGeneration: partialResponse, + previousGenerations: refinements, + sampleLastNGenerations: maxRepeatedGenerations, + }) + ) { + logger?.debug( + () => + `refine node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` + ); + + // discard the accumulated results and start over: + return discardPreviousRefinements({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: false, + state, + }); + } + + // LOCAL MUTATION: + combinedResponse = getCombined({ combinedGenerations: combinedRefinements, partialResponse }); // combine the new response with the previous ones + + const attackDiscoveries = parseCombinedOrThrow({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName: 'refine', + }); + + return { + ...state, + attackDiscoveries, // the final, refined answer + generationAttempts: generationAttempts + 1, + combinedRefinements: combinedResponse, + refinements: [...refinements, partialResponse], + }; + } catch (error) { + const parsingError = `refine node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; + logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response + + const maxRetriesReached = getMaxRetriesReached({ + generationAttempts: generationAttempts + 1, + maxGenerationAttempts, + }); + + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + // we will use the unrefined results if we have reached the maximum number of retries or hallucination failures: + const useUnrefinedResults = getUseUnrefinedResults({ + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + if (useUnrefinedResults) { + logger?.debug( + () => + `refine node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` + ); + } + + return { + ...state, + attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, + combinedRefinements: combinedResponse, + errors: [...state.errors, parsingError], + generationAttempts: generationAttempts + 1, + refinements: [...refinements, partialResponse], + }; + } + }; + + return refine; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts new file mode 100644 index 0000000000000..3a8b7ed3a6b94 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; +import type { Document } from '@langchain/core/documents'; +import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; + +import { getAnonymizedAlerts } from '../helpers/get_anonymized_alerts'; + +export type CustomRetrieverInput = BaseRetrieverInput; + +export class AnonymizedAlertsRetriever extends BaseRetriever { + lc_namespace = ['langchain', 'retrievers']; + + #alertsIndexPattern?: string; + #anonymizationFields?: AnonymizationFieldResponse[]; + #esClient: ElasticsearchClient; + #onNewReplacements?: (newReplacements: Replacements) => void; + #replacements?: Replacements; + #size?: number; + + constructor({ + alertsIndexPattern, + anonymizationFields, + fields, + esClient, + onNewReplacements, + replacements, + size, + }: { + alertsIndexPattern?: string; + anonymizationFields?: AnonymizationFieldResponse[]; + fields?: CustomRetrieverInput; + esClient: ElasticsearchClient; + onNewReplacements?: (newReplacements: Replacements) => void; + replacements?: Replacements; + size?: number; + }) { + super(fields); + + this.#alertsIndexPattern = alertsIndexPattern; + this.#anonymizationFields = anonymizationFields; + this.#esClient = esClient; + this.#onNewReplacements = onNewReplacements; + this.#replacements = replacements; + this.#size = size; + } + + async _getRelevantDocuments( + query: string, + runManager?: CallbackManagerForRetrieverRun + ): Promise<Document[]> { + const anonymizedAlerts = await getAnonymizedAlerts({ + alertsIndexPattern: this.#alertsIndexPattern, + anonymizationFields: this.#anonymizationFields, + esClient: this.#esClient, + onNewReplacements: this.#onNewReplacements, + replacements: this.#replacements, + size: this.#size, + }); + + return anonymizedAlerts.map((alert) => ({ + pageContent: alert, + metadata: {}, + })); + } +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts similarity index 90% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts index 6b7526870eb9f..b616c392ddd21 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts @@ -6,19 +6,19 @@ */ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { getOpenAndAcknowledgedAlertsQuery } from '@kbn/elastic-assistant-common'; -import { getAnonymizedAlerts } from './get_anonymized_alerts'; -import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; -import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; -import { MIN_SIZE } from '../open_and_acknowledged_alerts/helpers'; +const MIN_SIZE = 10; -jest.mock('../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query', () => { - const original = jest.requireActual( - '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query' - ); +import { getAnonymizedAlerts } from '.'; +import { mockOpenAndAcknowledgedAlertsQueryResults } from '../../../../mock/mock_open_and_acknowledged_alerts_query_results'; + +jest.mock('@kbn/elastic-assistant-common', () => { + const original = jest.requireActual('@kbn/elastic-assistant-common'); return { - getOpenAndAcknowledgedAlertsQuery: jest.fn(() => original), + ...original, + getOpenAndAcknowledgedAlertsQuery: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts similarity index 77% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts index 5989caf439518..bc2a7f5bf9e71 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts @@ -7,12 +7,16 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Replacements } from '@kbn/elastic-assistant-common'; -import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; -import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { + Replacements, + getAnonymizedValue, + getOpenAndAcknowledgedAlertsQuery, + getRawDataOrDefault, + sizeIsOutOfRange, + transformRawData, +} from '@kbn/elastic-assistant-common'; -import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; -import { getRawDataOrDefault, sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; export const getAnonymizedAlerts = async ({ alertsIndexPattern, diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts new file mode 100644 index 0000000000000..951ae3bca8854 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +import { AnonymizedAlertsRetriever } from './anonymized_alerts_retriever'; +import type { GraphState } from '../../types'; + +export const getRetrieveAnonymizedAlertsNode = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + logger, + onNewReplacements, + replacements, + size, +}: { + alertsIndexPattern?: string; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + logger?: Logger; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; + size?: number; +}): ((state: GraphState) => Promise<GraphState>) => { + let localReplacements = { ...(replacements ?? {}) }; + const localOnNewReplacements = (newReplacements: Replacements) => { + localReplacements = { ...localReplacements, ...newReplacements }; + + onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements + }; + + const retriever = new AnonymizedAlertsRetriever({ + alertsIndexPattern, + anonymizationFields, + esClient, + onNewReplacements: localOnNewReplacements, + replacements, + size, + }); + + const retrieveAnonymizedAlerts = async (state: GraphState): Promise<GraphState> => { + logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS---'); + const documents = await retriever + .withConfig({ runName: 'runAnonymizedAlertsRetriever' }) + .invoke(''); + + return { + ...state, + anonymizedAlerts: documents, + replacements: localReplacements, + }; + }; + + return retrieveAnonymizedAlerts; +}; + +/** + * Retrieve documents + * + * @param {GraphState} state The current state of the graph. + * @param {RunnableConfig | undefined} config The configuration object for tracing. + * @returns {Promise<GraphState>} The new state object. + */ diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts new file mode 100644 index 0000000000000..4229155cc2e25 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import type { Document } from '@langchain/core/documents'; +import type { StateGraphArgs } from '@langchain/langgraph'; + +import { + DEFAULT_MAX_GENERATION_ATTEMPTS, + DEFAULT_MAX_HALLUCINATION_FAILURES, + DEFAULT_MAX_REPEATED_GENERATIONS, +} from '../constants'; +import { getDefaultAttackDiscoveryPrompt } from '../nodes/helpers/get_default_attack_discovery_prompt'; +import { getDefaultRefinePrompt } from '../nodes/refine/helpers/get_default_refine_prompt'; +import type { GraphState } from '../types'; + +export const getDefaultGraphState = (): StateGraphArgs<GraphState>['channels'] => ({ + attackDiscoveries: { + value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, + default: () => null, + }, + attackDiscoveryPrompt: { + value: (x: string, y?: string) => y ?? x, + default: () => getDefaultAttackDiscoveryPrompt(), + }, + anonymizedAlerts: { + value: (x: Document[], y?: Document[]) => y ?? x, + default: () => [], + }, + combinedGenerations: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + combinedRefinements: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + errors: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + generationAttempts: { + value: (x: number, y?: number) => y ?? x, + default: () => 0, + }, + generations: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + hallucinationFailures: { + value: (x: number, y?: number) => y ?? x, + default: () => 0, + }, + refinePrompt: { + value: (x: string, y?: string) => y ?? x, + default: () => getDefaultRefinePrompt(), + }, + maxGenerationAttempts: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_GENERATION_ATTEMPTS, + }, + maxHallucinationFailures: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_HALLUCINATION_FAILURES, + }, + maxRepeatedGenerations: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_REPEATED_GENERATIONS, + }, + refinements: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + replacements: { + value: (x: Replacements, y?: Replacements) => y ?? x, + default: () => ({}), + }, + unrefinedResults: { + value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, + default: () => null, + }, +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts new file mode 100644 index 0000000000000..b4473a02b82ae --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import type { Document } from '@langchain/core/documents'; + +export interface GraphState { + attackDiscoveries: AttackDiscovery[] | null; + attackDiscoveryPrompt: string; + anonymizedAlerts: Document[]; + combinedGenerations: string; + combinedRefinements: string; + errors: string[]; + generationAttempts: number; + generations: string[]; + hallucinationFailures: number; + maxGenerationAttempts: number; + maxHallucinationFailures: number; + maxRepeatedGenerations: number; + refinements: string[]; + refinePrompt: string; + replacements: Replacements; + unrefinedResults: AttackDiscovery[] | null; +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts similarity index 94% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts index 6e9cc39597bd7..a82ec24c7041e 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts @@ -10,11 +10,11 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { createAttackDiscovery } from './create_attack_discovery'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; import { loggerMock } from '@kbn/logging-mocks'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); -jest.mock('./get_attack_discovery'); +jest.mock('../get_attack_discovery/get_attack_discovery'); const attackDiscoveryCreate: AttackDiscoveryCreateProps = { attackDiscoveries: [], apiConfig: { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts index 7304ab3488529..fc511dc559d30 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts @@ -9,8 +9,8 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { getAttackDiscovery } from './get_attack_discovery'; -import { CreateAttackDiscoverySchema } from './types'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; +import { CreateAttackDiscoverySchema } from '../types'; export interface CreateAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts similarity index 100% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts index e80d1e4589838..945603b517938 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts @@ -8,8 +8,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; const MAX_ITEMS = 10000; export interface FindAllAttackDiscoveriesParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts index 10688ce25b25e..53d74e6e92f42 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts @@ -9,7 +9,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts index 532c35ac89c05..07fde44080026 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; export interface FindAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts index 4ee89fb7a3bc0..af1a1827cbddd 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts @@ -8,7 +8,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { getAttackDiscovery } from './get_attack_discovery'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; import { AuthenticatedUser } from '@kbn/core-security-common'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts index d0cf6fd19ae05..ae2051d9e480b 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; export interface GetAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts index ca053743c8035..5aac100f5f52c 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts @@ -11,12 +11,15 @@ import { AttackDiscoveryResponse, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { findAllAttackDiscoveries } from './find_all_attack_discoveries'; -import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; -import { updateAttackDiscovery } from './update_attack_discovery'; -import { createAttackDiscovery } from './create_attack_discovery'; -import { getAttackDiscovery } from './get_attack_discovery'; -import { AIAssistantDataClient, AIAssistantDataClientParams } from '..'; +import { findAllAttackDiscoveries } from './find_all_attack_discoveries/find_all_attack_discoveries'; +import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id'; +import { updateAttackDiscovery } from './update_attack_discovery/update_attack_discovery'; +import { createAttackDiscovery } from './create_attack_discovery/create_attack_discovery'; +import { getAttackDiscovery } from './get_attack_discovery/get_attack_discovery'; +import { + AIAssistantDataClient, + AIAssistantDataClientParams, +} from '../../../ai_assistant_data_clients'; type AttackDiscoveryDataClientParams = AIAssistantDataClientParams; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts similarity index 98% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts index d9a37582f48b0..765d40f7a3226 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts @@ -7,7 +7,7 @@ import { estypes } from '@elastic/elasticsearch'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; +import { EsAttackDiscoverySchema } from '../types'; export const transformESSearchToAttackDiscovery = ( response: estypes.SearchResponse<EsAttackDiscoverySchema> diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts index 4a17c50e06af4..08be262fede5a 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts @@ -6,7 +6,7 @@ */ import { AttackDiscoveryStatus, Provider } from '@kbn/elastic-assistant-common'; -import { EsReplacementSchema } from '../conversations/types'; +import { EsReplacementSchema } from '../../../ai_assistant_data_clients/conversations/types'; export interface EsAttackDiscoverySchema { '@timestamp': string; @@ -53,7 +53,7 @@ export interface CreateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown: string; + entity_summary_markdown?: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts similarity index 97% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts index 24deda445f320..8d98839c092aa 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts @@ -7,7 +7,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; import { updateAttackDiscovery } from './update_attack_discovery'; import { AttackDiscoveryResponse, @@ -15,7 +15,7 @@ import { AttackDiscoveryUpdateProps, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -jest.mock('./get_attack_discovery'); +jest.mock('../get_attack_discovery/get_attack_discovery'); const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); const user = { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts index 73a386bbb4362..c810a71c5f1a3 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts @@ -14,8 +14,8 @@ import { UUID, } from '@kbn/elastic-assistant-common'; import * as uuid from 'uuid'; -import { EsReplacementSchema } from '../conversations/types'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { EsReplacementSchema } from '../../../../ai_assistant_data_clients/conversations/types'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; export interface UpdateAttackDiscoverySchema { id: UUID; @@ -25,7 +25,7 @@ export interface UpdateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown: string; + entity_summary_markdown?: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts index 706da7197f31a..b9e4f85a800a0 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts @@ -10,14 +10,41 @@ import { GetDefaultAssistantGraphParams, DefaultAssistantGraph, } from './default_assistant_graph/graph'; +import { + DefaultAttackDiscoveryGraph, + GetDefaultAttackDiscoveryGraphParams, + getDefaultAttackDiscoveryGraph, +} from '../../attack_discovery/graphs/default_attack_discovery_graph'; export type GetAssistantGraph = (params: GetDefaultAssistantGraphParams) => DefaultAssistantGraph; +export type GetAttackDiscoveryGraph = ( + params: GetDefaultAttackDiscoveryGraphParams +) => DefaultAttackDiscoveryGraph; + +export type GraphType = 'assistant' | 'attack-discovery'; + +export interface AssistantGraphMetadata { + getDefaultAssistantGraph: GetAssistantGraph; + graphType: 'assistant'; +} + +export interface AttackDiscoveryGraphMetadata { + getDefaultAttackDiscoveryGraph: GetAttackDiscoveryGraph; + graphType: 'attack-discovery'; +} + +export type GraphMetadata = AssistantGraphMetadata | AttackDiscoveryGraphMetadata; /** * Map of the different Assistant Graphs. Useful for running evaluations. */ -export const ASSISTANT_GRAPH_MAP: Record<string, GetAssistantGraph> = { - DefaultAssistantGraph: getDefaultAssistantGraph, - // TODO: Support additional graphs - // AttackDiscoveryGraph: getDefaultAssistantGraph, +export const ASSISTANT_GRAPH_MAP: Record<string, GraphMetadata> = { + DefaultAssistantGraph: { + getDefaultAssistantGraph, + graphType: 'assistant', + }, + DefaultAttackDiscoveryGraph: { + getDefaultAttackDiscoveryGraph, + graphType: 'attack-discovery', + }, }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts similarity index 85% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts index 74cf160c43ffe..ce07d66b9606e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts @@ -8,15 +8,24 @@ import { getAttackDiscoveryRoute } from './get_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { requestContextMock } from '../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { getAttackDiscoveryRequest } from '../../__mocks__/request'; -import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from './helpers'; -jest.mock('./helpers'); +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoveryRequest } from '../../../__mocks__/request'; +import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from '../helpers/helpers'; + +jest.mock('../helpers/helpers', () => { + const original = jest.requireActual('../helpers/helpers'); + + return { + ...original, + getAttackDiscoveryStats: jest.fn(), + updateAttackDiscoveryLastViewedAt: jest.fn(), + }; +}); const mockStats = { newConnectorResultsCount: 2, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts index 09b2df98fe090..e3756b10a3fb3 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts @@ -14,10 +14,10 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from './helpers'; -import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../common/constants'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from '../helpers/helpers'; +import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../../common/constants'; +import { buildResponse } from '../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../types'; export const getAttackDiscoveryRoute = (router: IRouter<ElasticAssistantRequestHandlerContext>) => { router.versioned diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts deleted file mode 100644 index d5eaf7d159618..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts +++ /dev/null @@ -1,805 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AuthenticatedUser } from '@kbn/core-security-common'; -import moment from 'moment'; -import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; - -import { - REQUIRED_FOR_ATTACK_DISCOVERY, - addGenerationInterval, - attackDiscoveryStatus, - getAssistantToolParams, - handleToolError, - updateAttackDiscoveryStatusToCanceled, - updateAttackDiscoveryStatusToRunning, - updateAttackDiscoveries, - getAttackDiscoveryStats, -} from './helpers'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; -import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { loggerMock } from '@kbn/logging-mocks'; -import { KibanaRequest } from '@kbn/core-http-server'; -import { - AttackDiscoveryPostRequestBody, - ExecuteConnectorRequestBody, -} from '@kbn/elastic-assistant-common'; -import { coreMock } from '@kbn/core/server/mocks'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; - -import { - getAnonymizationFieldMock, - getUpdateAnonymizationFieldSchemaMock, -} from '../../__mocks__/anonymization_fields_schema.mock'; - -jest.mock('lodash/fp', () => ({ - uniq: jest.fn((arr) => Array.from(new Set(arr))), -})); - -jest.mock('@kbn/securitysolution-es-utils', () => ({ - transformError: jest.fn((err) => err), -})); -jest.mock('@kbn/langchain/server', () => ({ - ActionsClientLlm: jest.fn(), -})); -jest.mock('../evaluate/utils', () => ({ - getLangSmithTracer: jest.fn().mockReturnValue([]), -})); -jest.mock('../utils', () => ({ - getLlmType: jest.fn().mockReturnValue('llm-type'), -})); -const findAttackDiscoveryByConnectorId = jest.fn(); -const updateAttackDiscovery = jest.fn(); -const createAttackDiscovery = jest.fn(); -const getAttackDiscovery = jest.fn(); -const findAllAttackDiscoveries = jest.fn(); -const mockDataClient = { - findAttackDiscoveryByConnectorId, - updateAttackDiscovery, - createAttackDiscovery, - getAttackDiscovery, - findAllAttackDiscoveries, -} as unknown as AttackDiscoveryDataClient; -const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); -const mockLogger = loggerMock.create(); -const mockTelemetry = coreMock.createSetup().analytics; -const mockError = new Error('Test error'); - -const mockAuthenticatedUser = { - username: 'user', - profile_uid: '1234', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; - -const mockApiConfig = { - connectorId: 'connector-id', - actionTypeId: '.bedrock', - model: 'model', - provider: OpenAiProviderType.OpenAi, -}; - -const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockRequest: KibanaRequest<unknown, unknown, any, any> = {} as unknown as KibanaRequest< - unknown, - unknown, - any, // eslint-disable-line @typescript-eslint/no-explicit-any - any // eslint-disable-line @typescript-eslint/no-explicit-any ->; - -describe('helpers', () => { - const date = '2024-03-28T22:27:28.000Z'; - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - beforeEach(() => { - jest.clearAllMocks(); - jest.setSystemTime(new Date(date)); - getAttackDiscovery.mockResolvedValue(mockCurrentAd); - updateAttackDiscovery.mockResolvedValue({}); - }); - describe('getAssistantToolParams', () => { - const alertsIndexPattern = '.alerts-security.alerts-default'; - const esClient = elasticsearchClientMock.createElasticsearchClient(); - const actionsClient = actionsClientMock.create(); - const langChainTimeout = 1000; - const latestReplacements = {}; - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: 'test-connecter-id', - llmType: 'bedrock', - logger: mockLogger, - temperature: 0, - timeout: 580000, - }); - const onNewReplacements = jest.fn(); - const size = 20; - - const mockParams = { - actionsClient, - alertsIndexPattern: 'alerts-*', - anonymizationFields: [{ id: '1', field: 'field1', allowed: true, anonymized: true }], - apiConfig: mockApiConfig, - esClient: mockEsClient, - connectorTimeout: 1000, - langChainTimeout: 2000, - langSmithProject: 'project', - langSmithApiKey: 'api-key', - logger: mockLogger, - latestReplacements: {}, - onNewReplacements: jest.fn(), - request: {} as KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >, - size: 10, - }; - - it('should return formatted assistant tool params', () => { - const result = getAssistantToolParams(mockParams); - - expect(ActionsClientLlm).toHaveBeenCalledWith( - expect.objectContaining({ - connectorId: 'connector-id', - llmType: 'llm-type', - }) - ); - expect(result.anonymizationFields).toEqual([ - ...mockParams.anonymizationFields, - ...REQUIRED_FOR_ATTACK_DISCOVERY, - ]); - }); - - it('returns the expected AssistantToolParams when anonymizationFields are provided', () => { - const anonymizationFields = [ - getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()), - ]; - - const result = getAssistantToolParams({ - actionsClient, - alertsIndexPattern, - apiConfig: mockApiConfig, - anonymizationFields, - connectorTimeout: 1000, - latestReplacements, - esClient, - langChainTimeout, - logger: mockLogger, - onNewReplacements, - request: mockRequest, - size, - }); - - expect(result).toEqual({ - alertsIndexPattern, - anonymizationFields: [...anonymizationFields, ...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, - chain: undefined, - esClient, - langChainTimeout, - llm, - logger: mockLogger, - onNewReplacements, - replacements: latestReplacements, - request: mockRequest, - size, - }); - }); - - it('returns the expected AssistantToolParams when anonymizationFields is undefined', () => { - const anonymizationFields = undefined; - - const result = getAssistantToolParams({ - actionsClient, - alertsIndexPattern, - apiConfig: mockApiConfig, - anonymizationFields, - connectorTimeout: 1000, - latestReplacements, - esClient, - langChainTimeout, - logger: mockLogger, - onNewReplacements, - request: mockRequest, - size, - }); - - expect(result).toEqual({ - alertsIndexPattern, - anonymizationFields: [...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, - chain: undefined, - esClient, - langChainTimeout, - llm, - logger: mockLogger, - onNewReplacements, - replacements: latestReplacements, - request: mockRequest, - size, - }); - }); - - describe('addGenerationInterval', () => { - const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 }; - const existingIntervals = [ - { date: '2024-01-02T00:00:00Z', durationMs: 2000 }, - { date: '2024-01-03T00:00:00Z', durationMs: 3000 }, - ]; - - it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => { - const result = addGenerationInterval(existingIntervals, generationInterval); - expect(result.length).toBeLessThanOrEqual(5); - expect(result).toContain(generationInterval); - }); - - it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => { - const longExistingIntervals = [...Array(5)].map((_, i) => ({ - date: `2024-01-0${i + 2}T00:00:00Z`, - durationMs: (i + 2) * 1000, - })); - const result = addGenerationInterval(longExistingIntervals, generationInterval); - expect(result.length).toBe(5); - expect(result).not.toContain(longExistingIntervals[4]); - }); - }); - - describe('updateAttackDiscoveryStatusToRunning', () => { - it('should update existing attack discovery to running', async () => { - const existingAd = { id: 'existing-id', backingIndex: 'index' }; - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(existingAd); - - const result = await updateAttackDiscoveryStatusToRunning( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig - ); - - expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ - connectorId: mockApiConfig.connectorId, - authenticatedUser: mockAuthenticatedUser, - }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: expect.objectContaining({ - status: attackDiscoveryStatus.running, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd }); - }); - - it('should create a new attack discovery if none exists', async () => { - const newAd = { id: 'new-id', backingIndex: 'index' }; - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - createAttackDiscovery.mockResolvedValue(newAd); - - const result = await updateAttackDiscoveryStatusToRunning( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig - ); - - expect(createAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryCreate: expect.objectContaining({ - status: attackDiscoveryStatus.running, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd }); - }); - - it('should throw an error if updating or creating attack discovery fails', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - createAttackDiscovery.mockResolvedValue(null); - - await expect( - updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig) - ).rejects.toThrow('Could not create attack discovery for connectorId: connector-id'); - }); - }); - - describe('updateAttackDiscoveryStatusToCanceled', () => { - const existingAd = { - id: 'existing-id', - backingIndex: 'index', - status: attackDiscoveryStatus.running, - }; - it('should update existing attack discovery to canceled', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(existingAd); - - const result = await updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ); - - expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ - connectorId: mockApiConfig.connectorId, - authenticatedUser: mockAuthenticatedUser, - }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: expect.objectContaining({ - status: attackDiscoveryStatus.canceled, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual(existingAd); - }); - - it('should throw an error if attack discovery is not running', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue({ - ...existingAd, - status: attackDiscoveryStatus.succeeded, - }); - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow( - 'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.' - ); - }); - - it('should throw an error if attack discovery does not exist', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow('Could not find attack discovery for connector id: connector-id'); - }); - it('should throw error if updateAttackDiscovery returns null', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(null); - - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow('Could not update attack discovery for connector id: connector-id'); - }); - }); - - describe('updateAttackDiscoveries', () => { - const mockAttackDiscoveryId = 'attack-discovery-id'; - const mockLatestReplacements = {}; - const mockRawAttackDiscoveries = JSON.stringify({ - alertsContextCount: 5, - attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], - }); - const mockSize = 10; - const mockStartTime = moment('2024-03-28T22:25:28.000Z'); - - const mockArgs = { - apiConfig: mockApiConfig, - attackDiscoveryId: mockAttackDiscoveryId, - authenticatedUser: mockAuthenticatedUser, - dataClient: mockDataClient, - latestReplacements: mockLatestReplacements, - logger: mockLogger, - rawAttackDiscoveries: mockRawAttackDiscoveries, - size: mockSize, - startTime: mockStartTime, - telemetry: mockTelemetry, - }; - - it('should update attack discoveries and report success telemetry', async () => { - await updateAttackDiscoveries(mockArgs); - - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - alertsContextCount: 5, - attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], - status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - generationIntervals: [ - { date, durationMs: 120000 }, - ...mockCurrentAd.generationIntervals, - ], - }, - authenticatedUser: mockAuthenticatedUser, - }); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { - actionTypeId: mockApiConfig.actionTypeId, - alertsContextCount: 5, - alertsCount: 3, - configuredAlertsCount: mockSize, - discoveriesGenerated: 2, - durationMs: 120000, - model: mockApiConfig.model, - provider: mockApiConfig.provider, - }); - }); - - it('should update attack discoveries without generation interval if no discoveries are found', async () => { - const noDiscoveriesRaw = JSON.stringify({ - alertsContextCount: 0, - attackDiscoveries: [], - }); - - await updateAttackDiscoveries({ - ...mockArgs, - rawAttackDiscoveries: noDiscoveriesRaw, - }); - - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - alertsContextCount: 0, - attackDiscoveries: [], - status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - }, - authenticatedUser: mockAuthenticatedUser, - }); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { - actionTypeId: mockApiConfig.actionTypeId, - alertsContextCount: 0, - alertsCount: 0, - configuredAlertsCount: mockSize, - discoveriesGenerated: 0, - durationMs: 120000, - model: mockApiConfig.model, - provider: mockApiConfig.provider, - }); - }); - - it('should catch and log an error if raw attack discoveries is null', async () => { - await updateAttackDiscoveries({ - ...mockArgs, - rawAttackDiscoveries: null, - }); - expect(mockLogger.error).toHaveBeenCalledTimes(1); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: 'tool returned no attack discoveries', - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { - getAttackDiscovery.mockResolvedValue({ - ...mockCurrentAd, - status: attackDiscoveryStatus.canceled, - }); - await updateAttackDiscoveries(mockArgs); - - expect(mockLogger.error).not.toHaveBeenCalled(); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - }); - - it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { - getAttackDiscovery.mockRejectedValue(mockError); - await updateAttackDiscoveries(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - }); - - describe('handleToolError', () => { - const mockArgs = { - apiConfig: mockApiConfig, - attackDiscoveryId: 'discovery-id', - authenticatedUser: mockAuthenticatedUser, - backingIndex: 'backing-index', - dataClient: mockDataClient, - err: mockError, - latestReplacements: {}, - logger: mockLogger, - telemetry: mockTelemetry, - }; - - it('should log the error and update attack discovery status to failed', async () => { - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - status: attackDiscoveryStatus.failed, - attackDiscoveries: [], - backingIndex: 'foo', - failureReason: 'Test error', - id: 'discovery-id', - replacements: {}, - }, - authenticatedUser: mockArgs.authenticatedUser, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => { - updateAttackDiscovery.mockRejectedValue(mockError); - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - status: attackDiscoveryStatus.failed, - attackDiscoveries: [], - backingIndex: 'foo', - failureReason: 'Test error', - id: 'discovery-id', - replacements: {}, - }, - authenticatedUser: mockArgs.authenticatedUser, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { - getAttackDiscovery.mockResolvedValue({ - ...mockCurrentAd, - status: attackDiscoveryStatus.canceled, - }); - await handleToolError(mockArgs); - - expect(mockTelemetry.reportEvent).not.toHaveBeenCalled(); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - }); - - it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { - getAttackDiscovery.mockRejectedValue(mockError); - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - }); - }); - describe('getAttackDiscoveryStats', () => { - const mockDiscoveries = [ - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:47:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:46:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-12T19:54:50.428Z', - id: '745e005b-7248-4c08-b8b6-4cad263b4be0', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T19:54:50.428Z', - updatedAt: '2024-06-17T20:47:27.182Z', - lastViewedAt: '2024-06-17T20:27:27.182Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'running', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gen-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-13T17:50:59.409Z', - id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:50:59.409Z', - updatedAt: '2024-06-17T20:47:59.969Z', - lastViewedAt: '2024-06-17T20:47:35.227Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gpt4o-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T21:18:56.377Z', - id: '82fced1d-de48-42db-9f56-e45122dee017', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T21:18:56.377Z', - updatedAt: '2024-06-17T20:47:39.372Z', - lastViewedAt: '2024-06-17T20:47:39.372Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'canceled', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-bedrock', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T16:44:23.107Z', - id: 'a4709094-6116-484b-b096-1e8d151cb4b7', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T16:44:23.107Z', - updatedAt: '2024-06-17T20:48:16.961Z', - lastViewedAt: '2024-06-17T20:47:16.961Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 0, - apiConfig: { - connectorId: 'my-gen-a2i', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [ - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: 'steph threw an error', - }, - ]; - beforeEach(() => { - findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); - }); - it('returns the formatted stats object', async () => { - const stats = await getAttackDiscoveryStats({ - authenticatedUser: mockAuthenticatedUser, - dataClient: mockDataClient, - }); - expect(stats).toEqual([ - { - hasViewed: true, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'running', - count: 1, - connectorId: 'my-gen-ai', - }, - { - hasViewed: false, - status: 'succeeded', - count: 1, - connectorId: 'my-gpt4o-ai', - }, - { - hasViewed: true, - status: 'canceled', - count: 1, - connectorId: 'my-bedrock', - }, - { - hasViewed: false, - status: 'succeeded', - count: 4, - connectorId: 'my-gen-a2i', - }, - ]); - }); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts new file mode 100644 index 0000000000000..2e0a545eb083a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AuthenticatedUser } from '@kbn/core-security-common'; + +import { getAttackDiscoveryStats } from './helpers'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; + +jest.mock('lodash/fp', () => ({ + uniq: jest.fn((arr) => Array.from(new Set(arr))), +})); + +jest.mock('@kbn/securitysolution-es-utils', () => ({ + transformError: jest.fn((err) => err), +})); +jest.mock('@kbn/langchain/server', () => ({ + ActionsClientLlm: jest.fn(), +})); +jest.mock('../../evaluate/utils', () => ({ + getLangSmithTracer: jest.fn().mockReturnValue([]), +})); +jest.mock('../../utils', () => ({ + getLlmType: jest.fn().mockReturnValue('llm-type'), +})); +const findAttackDiscoveryByConnectorId = jest.fn(); +const updateAttackDiscovery = jest.fn(); +const createAttackDiscovery = jest.fn(); +const getAttackDiscovery = jest.fn(); +const findAllAttackDiscoveries = jest.fn(); +const mockDataClient = { + findAttackDiscoveryByConnectorId, + updateAttackDiscovery, + createAttackDiscovery, + getAttackDiscovery, + findAllAttackDiscoveries, +} as unknown as AttackDiscoveryDataClient; + +const mockAuthenticatedUser = { + username: 'user', + profile_uid: '1234', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; + +const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; + +describe('helpers', () => { + const date = '2024-03-28T22:27:28.000Z'; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { + jest.clearAllMocks(); + jest.setSystemTime(new Date(date)); + getAttackDiscovery.mockResolvedValue(mockCurrentAd); + updateAttackDiscovery.mockResolvedValue({}); + }); + + describe('getAttackDiscoveryStats', () => { + const mockDiscoveries = [ + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:47:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:46:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-12T19:54:50.428Z', + id: '745e005b-7248-4c08-b8b6-4cad263b4be0', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T19:54:50.428Z', + updatedAt: '2024-06-17T20:47:27.182Z', + lastViewedAt: '2024-06-17T20:27:27.182Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'running', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gen-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-13T17:50:59.409Z', + id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:50:59.409Z', + updatedAt: '2024-06-17T20:47:59.969Z', + lastViewedAt: '2024-06-17T20:47:35.227Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gpt4o-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T21:18:56.377Z', + id: '82fced1d-de48-42db-9f56-e45122dee017', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T21:18:56.377Z', + updatedAt: '2024-06-17T20:47:39.372Z', + lastViewedAt: '2024-06-17T20:47:39.372Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'canceled', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-bedrock', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T16:44:23.107Z', + id: 'a4709094-6116-484b-b096-1e8d151cb4b7', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T16:44:23.107Z', + updatedAt: '2024-06-17T20:48:16.961Z', + lastViewedAt: '2024-06-17T20:47:16.961Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 0, + apiConfig: { + connectorId: 'my-gen-a2i', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [ + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: 'steph threw an error', + }, + ]; + beforeEach(() => { + findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); + }); + it('returns the formatted stats object', async () => { + const stats = await getAttackDiscoveryStats({ + authenticatedUser: mockAuthenticatedUser, + dataClient: mockDataClient, + }); + expect(stats).toEqual([ + { + hasViewed: true, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: false, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts similarity index 55% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts index f016d6ac29118..188976f0b3f5c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts @@ -5,38 +5,29 @@ * 2.0. */ -import { AnalyticsServiceSetup, AuthenticatedUser, KibanaRequest, Logger } from '@kbn/core/server'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; import { ApiConfig, AttackDiscovery, - AttackDiscoveryPostRequestBody, AttackDiscoveryResponse, AttackDiscoveryStat, AttackDiscoveryStatus, - ExecuteConnectorRequestBody, GenerationInterval, Replacements, } from '@kbn/elastic-assistant-common'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { Document } from '@langchain/core/documents'; import { v4 as uuidv4 } from 'uuid'; -import { ActionsClientLlm } from '@kbn/langchain/server'; - import { Moment } from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; -import type { ActionsClient } from '@kbn/actions-plugin/server'; import moment from 'moment/moment'; import { uniq } from 'lodash/fp'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import { getLlmType } from '../utils'; -import type { GetRegisteredTools } from '../../services/app_context'; + import { ATTACK_DISCOVERY_ERROR_EVENT, ATTACK_DISCOVERY_SUCCESS_EVENT, -} from '../../lib/telemetry/event_based_telemetry'; -import { AssistantToolParams } from '../../types'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +} from '../../../lib/telemetry/event_based_telemetry'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ { @@ -53,116 +44,6 @@ export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ }, ]; -export const getAssistantToolParams = ({ - actionsClient, - alertsIndexPattern, - anonymizationFields, - apiConfig, - esClient, - connectorTimeout, - langChainTimeout, - langSmithProject, - langSmithApiKey, - logger, - latestReplacements, - onNewReplacements, - request, - size, -}: { - actionsClient: PublicMethodsOf<ActionsClient>; - alertsIndexPattern: string; - anonymizationFields?: AnonymizationFieldResponse[]; - apiConfig: ApiConfig; - esClient: ElasticsearchClient; - connectorTimeout: number; - langChainTimeout: number; - langSmithProject?: string; - langSmithApiKey?: string; - logger: Logger; - latestReplacements: Replacements; - onNewReplacements: (newReplacements: Replacements) => void; - request: KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >; - size: number; -}) => { - const traceOptions = { - projectName: langSmithProject, - tracers: [ - ...getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: langSmithProject, - logger, - }), - ], - }; - - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: apiConfig.connectorId, - llmType: getLlmType(apiConfig.actionTypeId), - logger, - temperature: 0, // zero temperature for attack discovery, because we want structured JSON output - timeout: connectorTimeout, - traceOptions, - }); - - return formatAssistantToolParams({ - alertsIndexPattern, - anonymizationFields, - esClient, - latestReplacements, - langChainTimeout, - llm, - logger, - onNewReplacements, - request, - size, - }); -}; - -const formatAssistantToolParams = ({ - alertsIndexPattern, - anonymizationFields, - esClient, - langChainTimeout, - latestReplacements, - llm, - logger, - onNewReplacements, - request, - size, -}: { - alertsIndexPattern: string; - anonymizationFields?: AnonymizationFieldResponse[]; - esClient: ElasticsearchClient; - langChainTimeout: number; - latestReplacements: Replacements; - llm: ActionsClientLlm; - logger: Logger; - onNewReplacements: (newReplacements: Replacements) => void; - request: KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >; - size: number; -}): Omit<AssistantToolParams, 'connectorId' | 'inference'> => ({ - alertsIndexPattern, - anonymizationFields: [...(anonymizationFields ?? []), ...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, // not required for attack discovery - esClient, - langChainTimeout, - llm, - logger, - onNewReplacements, - replacements: latestReplacements, - request, - size, -}); - export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = { canceled: 'canceled', failed: 'failed', @@ -187,7 +68,8 @@ export const addGenerationInterval = ( export const updateAttackDiscoveryStatusToRunning = async ( dataClient: AttackDiscoveryDataClient, authenticatedUser: AuthenticatedUser, - apiConfig: ApiConfig + apiConfig: ApiConfig, + alertsContextCount: number ): Promise<{ currentAd: AttackDiscoveryResponse; attackDiscoveryId: string; @@ -199,6 +81,7 @@ export const updateAttackDiscoveryStatusToRunning = async ( const currentAd = foundAttackDiscovery ? await dataClient?.updateAttackDiscovery({ attackDiscoveryUpdateProps: { + alertsContextCount, backingIndex: foundAttackDiscovery.backingIndex, id: foundAttackDiscovery.id, status: attackDiscoveryStatus.running, @@ -207,6 +90,7 @@ export const updateAttackDiscoveryStatusToRunning = async ( }) : await dataClient?.createAttackDiscovery({ attackDiscoveryCreate: { + alertsContextCount, apiConfig, attackDiscoveries: [], status: attackDiscoveryStatus.running, @@ -261,38 +145,32 @@ export const updateAttackDiscoveryStatusToCanceled = async ( return updatedAttackDiscovery; }; -const getDataFromJSON = (adStringified: string) => { - const { alertsContextCount, attackDiscoveries } = JSON.parse(adStringified); - return { alertsContextCount, attackDiscoveries }; -}; - export const updateAttackDiscoveries = async ({ + anonymizedAlerts, apiConfig, + attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, - rawAttackDiscoveries, size, startTime, telemetry, }: { + anonymizedAlerts: Document[]; apiConfig: ApiConfig; + attackDiscoveries: AttackDiscovery[] | null; attackDiscoveryId: string; authenticatedUser: AuthenticatedUser; dataClient: AttackDiscoveryDataClient; latestReplacements: Replacements; logger: Logger; - rawAttackDiscoveries: string | null; size: number; startTime: Moment; telemetry: AnalyticsServiceSetup; }) => { try { - if (rawAttackDiscoveries == null) { - throw new Error('tool returned no attack discoveries'); - } const currentAd = await dataClient.getAttackDiscovery({ id: attackDiscoveryId, authenticatedUser, @@ -302,12 +180,12 @@ export const updateAttackDiscoveries = async ({ } const endTime = moment(); const durationMs = endTime.diff(startTime); - const { alertsContextCount, attackDiscoveries } = getDataFromJSON(rawAttackDiscoveries); + const alertsContextCount = anonymizedAlerts.length; const updateProps = { alertsContextCount, - attackDiscoveries, + attackDiscoveries: attackDiscoveries ?? undefined, status: attackDiscoveryStatus.succeeded, - ...(alertsContextCount === 0 || attackDiscoveries === 0 + ...(alertsContextCount === 0 ? {} : { generationIntervals: addGenerationInterval(currentAd.generationIntervals, { @@ -327,13 +205,14 @@ export const updateAttackDiscoveries = async ({ telemetry.reportEvent(ATTACK_DISCOVERY_SUCCESS_EVENT.eventType, { actionTypeId: apiConfig.actionTypeId, alertsContextCount: updateProps.alertsContextCount, - alertsCount: uniq( - updateProps.attackDiscoveries.flatMap( - (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds - ) - ).length, + alertsCount: + uniq( + updateProps.attackDiscoveries?.flatMap( + (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds + ) + ).length ?? 0, configuredAlertsCount: size, - discoveriesGenerated: updateProps.attackDiscoveries.length, + discoveriesGenerated: updateProps.attackDiscoveries?.length ?? 0, durationMs, model: apiConfig.model, provider: apiConfig.provider, @@ -350,70 +229,6 @@ export const updateAttackDiscoveries = async ({ } }; -export const handleToolError = async ({ - apiConfig, - attackDiscoveryId, - authenticatedUser, - dataClient, - err, - latestReplacements, - logger, - telemetry, -}: { - apiConfig: ApiConfig; - attackDiscoveryId: string; - authenticatedUser: AuthenticatedUser; - dataClient: AttackDiscoveryDataClient; - err: Error; - latestReplacements: Replacements; - logger: Logger; - telemetry: AnalyticsServiceSetup; -}) => { - try { - logger.error(err); - const error = transformError(err); - const currentAd = await dataClient.getAttackDiscovery({ - id: attackDiscoveryId, - authenticatedUser, - }); - - if (currentAd === null || currentAd?.status === 'canceled') { - return; - } - await dataClient.updateAttackDiscovery({ - attackDiscoveryUpdateProps: { - attackDiscoveries: [], - status: attackDiscoveryStatus.failed, - id: attackDiscoveryId, - replacements: latestReplacements, - backingIndex: currentAd.backingIndex, - failureReason: error.message, - }, - authenticatedUser, - }); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: error.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } catch (updateErr) { - const updateError = transformError(updateErr); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: updateError.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } -}; - -export const getAssistantTool = (getRegisteredTools: GetRegisteredTools, pluginName: string) => { - // get the attack discovery tool: - const assistantTools = getRegisteredTools(pluginName); - return assistantTools.find((tool) => tool.id === 'attack-discovery'); -}; - export const updateAttackDiscoveryLastViewedAt = async ({ connectorId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts similarity index 80% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts index 66aca77f1eb8b..9f5efbe5041d5 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts @@ -8,15 +8,23 @@ import { cancelAttackDiscoveryRoute } from './cancel_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../../__mocks__/server'; +import { requestContextMock } from '../../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { getCancelAttackDiscoveryRequest } from '../../__mocks__/request'; -import { updateAttackDiscoveryStatusToCanceled } from './helpers'; -jest.mock('./helpers'); +import { AttackDiscoveryDataClient } from '../../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; +import { getCancelAttackDiscoveryRequest } from '../../../../__mocks__/request'; +import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; + +jest.mock('../../helpers/helpers', () => { + const original = jest.requireActual('../../helpers/helpers'); + + return { + ...original, + updateAttackDiscoveryStatusToCanceled: jest.fn(), + }; +}); const { clients, context } = requestContextMock.createTools(); const server: ReturnType<typeof serverMock.create> = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts similarity index 91% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts index 47b748c9c432a..86631708b1cf7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts @@ -14,16 +14,16 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryStatusToCanceled } from './helpers'; -import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../common/constants'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; +import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../../../common/constants'; +import { buildResponse } from '../../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../../types'; export const cancelAttackDiscoveryRoute = ( router: IRouter<ElasticAssistantRequestHandlerContext> ) => { router.versioned - .put({ + .post({ access: 'internal', path: ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID, options: { diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx new file mode 100644 index 0000000000000..e58b67bdcc1ad --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; +import { ApiConfig, Replacements } from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { AttackDiscoveryDataClient } from '../../../../../lib/attack_discovery/persistence'; +import { attackDiscoveryStatus } from '../../../helpers/helpers'; +import { ATTACK_DISCOVERY_ERROR_EVENT } from '../../../../../lib/telemetry/event_based_telemetry'; + +export const handleGraphError = async ({ + apiConfig, + attackDiscoveryId, + authenticatedUser, + dataClient, + err, + latestReplacements, + logger, + telemetry, +}: { + apiConfig: ApiConfig; + attackDiscoveryId: string; + authenticatedUser: AuthenticatedUser; + dataClient: AttackDiscoveryDataClient; + err: Error; + latestReplacements: Replacements; + logger: Logger; + telemetry: AnalyticsServiceSetup; +}) => { + try { + logger.error(err); + const error = transformError(err); + const currentAd = await dataClient.getAttackDiscovery({ + id: attackDiscoveryId, + authenticatedUser, + }); + + if (currentAd === null || currentAd?.status === 'canceled') { + return; + } + + await dataClient.updateAttackDiscovery({ + attackDiscoveryUpdateProps: { + attackDiscoveries: [], + status: attackDiscoveryStatus.failed, + id: attackDiscoveryId, + replacements: latestReplacements, + backingIndex: currentAd.backingIndex, + failureReason: error.message, + }, + authenticatedUser, + }); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: error.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } catch (updateErr) { + const updateError = transformError(updateErr); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: updateError.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx new file mode 100644 index 0000000000000..8a8c49f796500 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { Logger } from '@kbn/core/server'; +import { ApiConfig, AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import type { Document } from '@langchain/core/documents'; + +import { getDefaultAttackDiscoveryGraph } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph'; +import { + ATTACK_DISCOVERY_GRAPH_RUN_NAME, + ATTACK_DISCOVERY_TAG, +} from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/constants'; +import { GraphState } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/types'; +import { throwIfErrorCountsExceeded } from '../throw_if_error_counts_exceeded'; +import { getLlmType } from '../../../../utils'; + +export const invokeAttackDiscoveryGraph = async ({ + actionsClient, + alertsIndexPattern, + anonymizationFields, + apiConfig, + connectorTimeout, + esClient, + langSmithProject, + langSmithApiKey, + latestReplacements, + logger, + onNewReplacements, + size, +}: { + actionsClient: PublicMethodsOf<ActionsClient>; + alertsIndexPattern: string; + anonymizationFields: AnonymizationFieldResponse[]; + apiConfig: ApiConfig; + connectorTimeout: number; + esClient: ElasticsearchClient; + langSmithProject?: string; + langSmithApiKey?: string; + latestReplacements: Replacements; + logger: Logger; + onNewReplacements: (newReplacements: Replacements) => void; + size: number; +}): Promise<{ + anonymizedAlerts: Document[]; + attackDiscoveries: AttackDiscovery[] | null; +}> => { + const llmType = getLlmType(apiConfig.actionTypeId); + const model = apiConfig.model; + const tags = [ATTACK_DISCOVERY_TAG, llmType, model].flatMap((tag) => tag ?? []); + + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: apiConfig.connectorId, + llmType, + logger, + temperature: 0, // zero temperature for attack discovery, because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + if (llm == null) { + throw new Error('LLM is required for attack discoveries'); + } + + const graph = getDefaultAttackDiscoveryGraph({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + onNewReplacements, + replacements: latestReplacements, + size, + }); + + logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph'); + + const result: GraphState = await graph.invoke( + {}, + { + callbacks: [...(traceOptions?.tracers ?? [])], + runName: ATTACK_DISCOVERY_GRAPH_RUN_NAME, + tags, + } + ); + const { + attackDiscoveries, + anonymizedAlerts, + errors, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + } = result; + + throwIfErrorCountsExceeded({ + errors, + generationAttempts, + hallucinationFailures, + logger, + maxGenerationAttempts, + maxHallucinationFailures, + }); + + return { anonymizedAlerts, attackDiscoveries }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx new file mode 100644 index 0000000000000..9cbf3fa06510d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; + +import { mockAnonymizationFields } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields'; +import { requestIsValid } from '.'; + +describe('requestIsValid', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const replacements = { uuid: 'original_value' }; + const size = 20; + const request = { + body: { + actionTypeId: '.bedrock', + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + connectorId: 'test-connector-id', + replacements, + size, + subAction: 'invokeAI', + }, + } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; + + it('returns false when the request is missing required anonymization parameters', () => { + const requestMissingAnonymizationParams = { + body: { + alertsIndexPattern: '.alerts-security.alerts-default', + isEnabledKnowledgeBase: false, + size: 20, + }, + } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; + + const params = { + alertsIndexPattern, + request: requestMissingAnonymizationParams, // <-- missing required anonymization parameters + size, + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when the alertsIndexPattern is undefined', () => { + const params = { + alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined + request, + size, + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when size is undefined', () => { + const params = { + alertsIndexPattern, + request, + size: undefined, // <-- size is undefined + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when size is out of range', () => { + const params = { + alertsIndexPattern, + request, + size: 0, // <-- size is out of range + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns true if all required params are provided', () => { + const params = { + alertsIndexPattern, + request, + size, + }; + + expect(requestIsValid(params)).toBe(true); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx new file mode 100644 index 0000000000000..36487d8f6b3e2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest } from '@kbn/core/server'; +import { + AttackDiscoveryPostRequestBody, + ExecuteConnectorRequestBody, + sizeIsOutOfRange, +} from '@kbn/elastic-assistant-common'; + +import { requestHasRequiredAnonymizationParams } from '../../../../../lib/langchain/helpers'; + +export const requestIsValid = ({ + alertsIndexPattern, + request, + size, +}: { + alertsIndexPattern: string | undefined; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + >; + size: number | undefined; +}): boolean => + requestHasRequiredAnonymizationParams(request) && + alertsIndexPattern != null && + size != null && + !sizeIsOutOfRange(size); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts new file mode 100644 index 0000000000000..409ee2da74cd2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import * as i18n from './translations'; + +export const throwIfErrorCountsExceeded = ({ + errors, + generationAttempts, + hallucinationFailures, + logger, + maxGenerationAttempts, + maxHallucinationFailures, +}: { + errors: string[]; + generationAttempts: number; + hallucinationFailures: number; + logger?: Logger; + maxGenerationAttempts: number; + maxHallucinationFailures: number; +}): void => { + if (hallucinationFailures >= maxHallucinationFailures) { + const hallucinationFailuresError = `${i18n.MAX_HALLUCINATION_FAILURES( + hallucinationFailures + )}\n${errors.join(',\n')}`; + + logger?.error(hallucinationFailuresError); + throw new Error(hallucinationFailuresError); + } + + if (generationAttempts >= maxGenerationAttempts) { + const generationAttemptsError = `${i18n.MAX_GENERATION_ATTEMPTS( + generationAttempts + )}\n${errors.join(',\n')}`; + + logger?.error(generationAttemptsError); + throw new Error(generationAttemptsError); + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts new file mode 100644 index 0000000000000..fbe06d0e73b2a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MAX_HALLUCINATION_FAILURES = (hallucinationFailures: number) => + i18n.translate( + 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage', + { + defaultMessage: + 'Maximum hallucination failures ({hallucinationFailures}) reached. Try sending fewer alerts to this model.', + values: { hallucinationFailures }, + } + ); + +export const MAX_GENERATION_ATTEMPTS = (generationAttempts: number) => + i18n.translate( + 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage', + { + defaultMessage: + 'Maximum generation attempts ({generationAttempts}) reached. Try sending fewer alerts to this model.', + values: { generationAttempts }, + } + ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts index cbd3e6063fbd2..d50987317b0e3 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts @@ -7,22 +7,27 @@ import { AuthenticatedUser } from '@kbn/core-security-common'; import { postAttackDiscoveryRoute } from './post_attack_discovery'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { requestContextMock } from '../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { postAttackDiscoveryRequest } from '../../__mocks__/request'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; +import { postAttackDiscoveryRequest } from '../../../__mocks__/request'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; import { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; -import { - getAssistantTool, - getAssistantToolParams, - updateAttackDiscoveryStatusToRunning, -} from './helpers'; -jest.mock('./helpers'); + +import { updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; + +jest.mock('../helpers/helpers', () => { + const original = jest.requireActual('../helpers/helpers'); + + return { + ...original, + updateAttackDiscoveryStatusToRunning: jest.fn(), + }; +}); const { clients, context } = requestContextMock.createTools(); const server: ReturnType<typeof serverMock.create> = serverMock.create(); @@ -72,8 +77,6 @@ describe('postAttackDiscoveryRoute', () => { context.elasticAssistant.actions = actionsMock.createStart(); postAttackDiscoveryRoute(server.router); findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd); - (getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() }); - (getAssistantToolParams as jest.Mock).mockReturnValue({ tool: 'tool' }); (updateAttackDiscoveryStatusToRunning as jest.Mock).mockResolvedValue({ currentAd: runningAd, attackDiscoveryId: mockCurrentAd.id, @@ -117,15 +120,6 @@ describe('postAttackDiscoveryRoute', () => { }); }); - it('should handle assistantTool null response', async () => { - (getAssistantTool as jest.Mock).mockReturnValue(null); - const response = await server.inject( - postAttackDiscoveryRequest(mockRequestBody), - requestContextMock.convertContext(context) - ); - expect(response.status).toEqual(404); - }); - it('should handle updateAttackDiscoveryStatusToRunning error', async () => { (updateAttackDiscoveryStatusToRunning as jest.Mock).mockRejectedValue(new Error('Oh no!')); const response = await server.inject( diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts index b9c680dde3d1d..b0273741bdf5e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; import { AttackDiscoveryPostRequestBody, @@ -13,20 +12,17 @@ import { ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, Replacements, } from '@kbn/elastic-assistant-common'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { transformError } from '@kbn/securitysolution-es-utils'; import moment from 'moment/moment'; -import { ATTACK_DISCOVERY } from '../../../common/constants'; -import { - getAssistantTool, - getAssistantToolParams, - handleToolError, - updateAttackDiscoveries, - updateAttackDiscoveryStatusToRunning, -} from './helpers'; -import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { ATTACK_DISCOVERY } from '../../../../common/constants'; +import { handleGraphError } from './helpers/handle_graph_error'; +import { updateAttackDiscoveries, updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; +import { buildResponse } from '../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../types'; +import { invokeAttackDiscoveryGraph } from './helpers/invoke_attack_discovery_graph'; +import { requestIsValid } from './helpers/request_is_valid'; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds @@ -85,11 +81,6 @@ export const postAttackDiscoveryRoute = ( statusCode: 500, }); } - const pluginName = getPluginNameFromRequest({ - request, - defaultPluginName: DEFAULT_PLUGIN_NAME, - logger, - }); // get parameters from the request body const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern); @@ -102,6 +93,19 @@ export const postAttackDiscoveryRoute = ( size, } = request.body; + if ( + !requestIsValid({ + alertsIndexPattern, + request, + size, + }) + ) { + return resp.error({ + body: 'Bad Request', + statusCode: 400, + }); + } + // get an Elasticsearch client for the authenticated user: const esClient = (await context.core).elasticsearch.client.asCurrentUser; @@ -111,59 +115,45 @@ export const postAttackDiscoveryRoute = ( latestReplacements = { ...latestReplacements, ...newReplacements }; }; - const assistantTool = getAssistantTool( - (await context.elasticAssistant).getRegisteredTools, - pluginName + const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( + dataClient, + authenticatedUser, + apiConfig, + size ); - if (!assistantTool) { - return response.notFound(); // attack discovery tool not found - } - - const assistantToolParams = getAssistantToolParams({ + // Don't await the results of invoking the graph; (just the metadata will be returned from the route handler): + invokeAttackDiscoveryGraph({ actionsClient, alertsIndexPattern, anonymizationFields, apiConfig, - esClient, - latestReplacements, connectorTimeout: CONNECTOR_TIMEOUT, - langChainTimeout: LANG_CHAIN_TIMEOUT, + esClient, langSmithProject, langSmithApiKey, + latestReplacements, logger, onNewReplacements, - request, size, - }); - - // invoke the attack discovery tool: - const toolInstance = assistantTool.getTool(assistantToolParams); - - const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( - dataClient, - authenticatedUser, - apiConfig - ); - - toolInstance - ?.invoke('') - .then((rawAttackDiscoveries: string) => + }) + .then(({ anonymizedAlerts, attackDiscoveries }) => updateAttackDiscoveries({ + anonymizedAlerts, apiConfig, + attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, - rawAttackDiscoveries, size, startTime, telemetry, }) ) .catch((err) => - handleToolError({ + handleGraphError({ apiConfig, attackDiscoveryId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts new file mode 100644 index 0000000000000..c0320c9ff6adf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ASSISTANT_GRAPH_MAP, + AssistantGraphMetadata, + AttackDiscoveryGraphMetadata, +} from '../../../lib/langchain/graphs'; + +export interface GetGraphsFromNamesResults { + attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; + assistantGraphs: AssistantGraphMetadata[]; +} + +export const getGraphsFromNames = (graphNames: string[]): GetGraphsFromNamesResults => + graphNames.reduce<GetGraphsFromNamesResults>( + (acc, graphName) => { + const graph = ASSISTANT_GRAPH_MAP[graphName]; + if (graph != null) { + return graph.graphType === 'assistant' + ? { ...acc, assistantGraphs: [...acc.assistantGraphs, graph] } + : { ...acc, attackDiscoveryGraphs: [...acc.attackDiscoveryGraphs, graph] }; + } + + return acc; + }, + { + attackDiscoveryGraphs: [], + assistantGraphs: [], + } + ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index 29a7527964677..eb12946a9b61f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -29,6 +29,7 @@ import { createStructuredChatAgent, createToolCallingAgent, } from 'langchain/agents'; +import { omit } from 'lodash/fp'; import { buildResponse } from '../../lib/build_response'; import { AssistantDataClients } from '../../lib/langchain/executors/types'; import { AssistantToolParams, ElasticAssistantRequestHandlerContext, GetElser } from '../../types'; @@ -36,6 +37,7 @@ import { DEFAULT_PLUGIN_NAME, isV2KnowledgeBaseEnabled, performChecks } from '.. import { fetchLangSmithDataset } from './utils'; import { transformESSearchToAnonymizationFields } from '../../ai_assistant_data_clients/anonymization_fields/helpers'; import { EsAnonymizationFieldsSchema } from '../../ai_assistant_data_clients/anonymization_fields/types'; +import { evaluateAttackDiscovery } from '../../lib/attack_discovery/evaluation'; import { DefaultAssistantGraph, getDefaultAssistantGraph, @@ -47,9 +49,12 @@ import { structuredChatAgentPrompt, } from '../../lib/langchain/graphs/default_assistant_graph/prompts'; import { getLlmClass, getLlmType, isOpenSourceModel } from '../utils'; +import { getGraphsFromNames } from './get_graphs_from_names'; const DEFAULT_SIZE = 20; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes +const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds +const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds export const postEvaluateRoute = ( router: IRouter<ElasticAssistantRequestHandlerContext>, @@ -106,8 +111,10 @@ export const postEvaluateRoute = ( const { alertsIndexPattern, datasetName, + evaluatorConnectorId, graphs: graphNames, langSmithApiKey, + langSmithProject, connectorIds, size, replacements, @@ -124,7 +131,9 @@ export const postEvaluateRoute = ( logger.info('postEvaluateRoute:'); logger.info(`request.query:\n${JSON.stringify(request.query, null, 2)}`); - logger.info(`request.body:\n${JSON.stringify(request.body, null, 2)}`); + logger.info( + `request.body:\n${JSON.stringify(omit(['langSmithApiKey'], request.body), null, 2)}` + ); logger.info(`Evaluation ID: ${evaluationId}`); const totalExecutions = connectorIds.length * graphNames.length * dataset.length; @@ -170,6 +179,38 @@ export const postEvaluateRoute = ( // Fetch any tools registered to the security assistant const assistantTools = assistantContext.getRegisteredTools(DEFAULT_PLUGIN_NAME); + const { attackDiscoveryGraphs } = getGraphsFromNames(graphNames); + + if (attackDiscoveryGraphs.length > 0) { + try { + // NOTE: we don't wait for the evaluation to finish here, because + // the client will retry / timeout when evaluations take too long + void evaluateAttackDiscovery({ + actionsClient, + alertsIndexPattern, + attackDiscoveryGraphs, + connectors, + connectorTimeout: CONNECTOR_TIMEOUT, + datasetName, + esClient, + evaluationId, + evaluatorConnectorId, + langSmithApiKey, + langSmithProject, + logger, + runName, + size, + }); + } catch (err) { + logger.error(() => `Error evaluating attack discovery: ${err}`); + } + + // Return early if we're only running attack discovery graphs + return response.ok({ + body: { evaluationId, success: true }, + }); + } + const graphs: Array<{ name: string; graph: DefaultAssistantGraph; diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts index 34f009e266515..0260c47b4bd29 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts @@ -21,7 +21,7 @@ export const fetchLangSmithDataset = async ( logger: Logger, langSmithApiKey?: string ): Promise<Example[]> => { - if (datasetName === undefined || !isLangSmithEnabled()) { + if (datasetName === undefined || (langSmithApiKey == null && !isLangSmithEnabled())) { throw new Error('LangSmith dataset name not provided or LangSmith not enabled'); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index 43e1229250f46..a6d7a4298c2b7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -9,8 +9,8 @@ export { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; // Attack Discovery -export { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; -export { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; +export { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; +export { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; // Knowledge Base export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index 56eb9760e442a..7898629e15b5c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -7,9 +7,9 @@ import type { Logger } from '@kbn/core/server'; -import { cancelAttackDiscoveryRoute } from './attack_discovery/cancel_attack_discovery'; -import { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; -import { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; +import { cancelAttackDiscoveryRoute } from './attack_discovery/post/cancel/cancel_attack_discovery'; +import { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; +import { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; import { ElasticAssistantPluginRouter, GetElser } from '../types'; import { createConversationRoute } from './user_conversations/create_route'; import { deleteConversationRoute } from './user_conversations/delete_route'; diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index 45bd5a4149b58..e84b97ab43d7a 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -43,10 +43,10 @@ import { ActionsClientGeminiChatModel, ActionsClientLlm, } from '@kbn/langchain/server'; - import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + import type { GetAIAssistantKnowledgeBaseDataClientParams } from './ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from './lib/attack_discovery/persistence'; import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations'; import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; import { AIAssistantDataClient } from './ai_assistant_data_clients'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx index 885ab18c879a7..dd995d115b6c3 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx @@ -6,7 +6,11 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { + replaceAnonymizedValuesWithOriginalValues, + type AttackDiscovery, + type Replacements, +} from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; import { AttackDiscoveryMarkdownFormatter } from '../../attack_discovery_markdown_formatter'; @@ -23,26 +27,41 @@ const ActionableSummaryComponent: React.FC<Props> = ({ replacements, showAnonymized = false, }) => { - const entitySummaryMarkdownWithReplacements = useMemo( + const entitySummary = useMemo( () => - Object.entries(replacements ?? {}).reduce( - (acc, [key, value]) => acc.replace(key, value), - attackDiscovery.entitySummaryMarkdown - ), - [attackDiscovery.entitySummaryMarkdown, replacements] + showAnonymized + ? attackDiscovery.entitySummaryMarkdown + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.entitySummaryMarkdown ?? '', + replacements: { ...replacements }, + }), + + [attackDiscovery.entitySummaryMarkdown, replacements, showAnonymized] + ); + + // title will be used as a fallback if entitySummaryMarkdown is empty + const title = useMemo( + () => + showAnonymized + ? attackDiscovery.title + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.title, + replacements: { ...replacements }, + }), + + [attackDiscovery.title, replacements, showAnonymized] ); + const entitySummaryOrTitle = + entitySummary != null && entitySummary.length > 0 ? entitySummary : title; + return ( <EuiPanel color="subdued" data-test-subj="actionableSummary"> <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> <EuiFlexItem data-test-subj="entitySummaryMarkdown" grow={false}> <AttackDiscoveryMarkdownFormatter disableActions={showAnonymized} - markdown={ - showAnonymized - ? attackDiscovery.entitySummaryMarkdown - : entitySummaryMarkdownWithReplacements - } + markdown={entitySummaryOrTitle} /> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx index 2aaac0449886a..c6ac9c70e8413 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx @@ -49,8 +49,15 @@ const AttackDiscoveryPanelComponent: React.FC<Props> = ({ ); const buttonContent = useMemo( - () => <Title isLoading={false} title={attackDiscovery.title} />, - [attackDiscovery.title] + () => ( + <Title + isLoading={false} + replacements={replacements} + showAnonymized={showAnonymized} + title={attackDiscovery.title} + /> + ), + [attackDiscovery.title, replacements, showAnonymized] ); return ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx index 4b0375e4fe503..13326a07adc70 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx @@ -7,20 +7,41 @@ import { EuiFlexGroup, EuiFlexItem, EuiSkeletonTitle, EuiTitle, useEuiTheme } from '@elastic/eui'; import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { + replaceAnonymizedValuesWithOriginalValues, + type Replacements, +} from '@kbn/elastic-assistant-common'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useMemo } from 'react'; const AVATAR_SIZE = 24; // px interface Props { isLoading: boolean; + replacements?: Replacements; + showAnonymized?: boolean; title: string; } -const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { +const TitleComponent: React.FC<Props> = ({ + isLoading, + replacements, + showAnonymized = false, + title, +}) => { const { euiTheme } = useEuiTheme(); + const titleWithReplacements = useMemo( + () => + replaceAnonymizedValuesWithOriginalValues({ + messageContent: title, + replacements: { ...replacements }, + }), + + [replacements, title] + ); + return ( <EuiFlexGroup alignItems="center" data-test-subj="title" gutterSize="s"> <EuiFlexItem @@ -53,7 +74,7 @@ const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { /> ) : ( <EuiTitle data-test-subj="titleText" size="xs"> - <h2>{title}</h2> + <h2>{showAnonymized ? title : titleWithReplacements}</h2> </EuiTitle> )} </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts index 5309ef1de6bb2..0ae524c25ee95 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts @@ -56,7 +56,7 @@ export const getAttackDiscoveryMarkdown = ({ replacements?: Replacements; }): string => { const title = getMarkdownFields(attackDiscovery.title); - const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown); + const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown ?? ''); const summaryMarkdown = getMarkdownFields(attackDiscovery.summaryMarkdown); const detailsMarkdown = getMarkdownFields(attackDiscovery.detailsMarkdown); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx index 874a4d1c99ded..ab0a5ac4ede96 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx @@ -106,7 +106,9 @@ export const usePollApi = ({ ...attackDiscovery, id: attackDiscovery.id ?? uuid.v4(), detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown), - entitySummaryMarkdown: replaceNewlineLiterals(attackDiscovery.entitySummaryMarkdown), + entitySummaryMarkdown: replaceNewlineLiterals( + attackDiscovery.entitySummaryMarkdown ?? '' + ), summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown), })), }; @@ -123,7 +125,7 @@ export const usePollApi = ({ const rawResponse = await http.fetch( `/internal/elastic_assistant/attack_discovery/cancel/${connectorId}`, { - method: 'PUT', + method: 'POST', version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, } ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx index 5dd4cb8fc4267..533b95bf7087f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx @@ -52,7 +52,7 @@ const AnimatedCounterComponent: React.FC<Props> = ({ animationDurationMs = 1000 css={css` height: 32px; margin-right: ${euiTheme.size.xs}; - width: ${count < 100 ? 40 : 53}px; + width: ${count < 100 ? 40 : 60}px; `} data-test-subj="animatedCounter" ref={d3Ref} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx index 56b2205b28726..0707950383046 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx @@ -16,6 +16,8 @@ jest.mock('../../../assistant/use_assistant_availability'); describe('EmptyPrompt', () => { const alertsCount = 20; + const aiConnectorsCount = 2; + const attackDiscoveriesCount = 0; const onGenerate = jest.fn(); beforeEach(() => { @@ -33,6 +35,8 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} + aiConnectorsCount={aiConnectorsCount} + attackDiscoveriesCount={attackDiscoveriesCount} isLoading={false} isDisabled={false} onGenerate={onGenerate} @@ -69,8 +73,34 @@ describe('EmptyPrompt', () => { }); describe('when loading is true', () => { - const isLoading = true; + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={0} // <-- no discoveries + alertsCount={alertsCount} + isLoading={true} // <-- loading + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); + expect(emptyPrompt).not.toBeInTheDocument(); + }); + }); + + describe('when aiConnectorsCount is null', () => { beforeEach(() => { (useAssistantAvailability as jest.Mock).mockReturnValue({ hasAssistantPrivilege: true, @@ -80,8 +110,10 @@ describe('EmptyPrompt', () => { render( <TestProviders> <EmptyPrompt + aiConnectorsCount={null} // <-- null + attackDiscoveriesCount={0} // <-- no discoveries alertsCount={alertsCount} - isLoading={isLoading} + isLoading={false} // <-- not loading isDisabled={false} onGenerate={onGenerate} /> @@ -89,10 +121,38 @@ describe('EmptyPrompt', () => { ); }); - it('disables the generate button while loading', () => { - const generateButton = screen.getByTestId('generate'); + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); - expect(generateButton).toBeDisabled(); + expect(emptyPrompt).not.toBeInTheDocument(); + }); + }); + + describe('when there are attack discoveries', () => { + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={7} // there are discoveries + alertsCount={alertsCount} + isLoading={false} // <-- not loading + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); + + expect(emptyPrompt).not.toBeInTheDocument(); }); }); @@ -109,6 +169,8 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={0} // <-- no discoveries isLoading={false} isDisabled={isDisabled} onGenerate={onGenerate} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx index 75c8533efcc92..3d89f5be87030 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx @@ -7,7 +7,6 @@ import { AssistantAvatar } from '@kbn/elastic-assistant'; import { - EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, @@ -15,24 +14,28 @@ import { EuiLink, EuiSpacer, EuiText, - EuiToolTip, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { AnimatedCounter } from './animated_counter'; +import { Generate } from '../generate'; import * as i18n from './translations'; interface Props { + aiConnectorsCount: number | null; // null when connectors are not configured alertsCount: number; + attackDiscoveriesCount: number; isDisabled?: boolean; isLoading: boolean; onGenerate: () => void; } const EmptyPromptComponent: React.FC<Props> = ({ + aiConnectorsCount, alertsCount, + attackDiscoveriesCount, isLoading, isDisabled = false, onGenerate, @@ -110,25 +113,13 @@ const EmptyPromptComponent: React.FC<Props> = ({ ); const actions = useMemo(() => { - const disabled = isLoading || isDisabled; - - return ( - <EuiToolTip - content={disabled ? i18n.SELECT_A_CONNECTOR : null} - data-test-subj="generateTooltip" - > - <EuiButton - color="primary" - data-test-subj="generate" - disabled={disabled} - onClick={onGenerate} - > - {i18n.GENERATE} - </EuiButton> - </EuiToolTip> - ); + return <Generate isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; }, [isDisabled, isLoading, onGenerate]); + if (isLoading || aiConnectorsCount == null || attackDiscoveriesCount > 0) { + return null; + } + return ( <EuiFlexGroup alignItems="center" diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts new file mode 100644 index 0000000000000..e2c7018ef5826 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + showEmptyPrompt, + showFailurePrompt, + showNoAlertsPrompt, + showWelcomePrompt, +} from '../../../helpers'; + +export const showEmptyStates = ({ + aiConnectorsCount, + alertsContextCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, +}: { + aiConnectorsCount: number | null; + alertsContextCount: number | null; + attackDiscoveriesCount: number; + connectorId: string | undefined; + failureReason: string | null; + isLoading: boolean; +}): boolean => { + const showWelcome = showWelcomePrompt({ aiConnectorsCount, isLoading }); + const showFailure = showFailurePrompt({ connectorId, failureReason, isLoading }); + const showNoAlerts = showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading }); + const showEmpty = showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading }); + + return showWelcome || showFailure || showNoAlerts || showEmpty; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx index 3b5b87ada83ec..9eacd696a2ff1 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { render, screen } from '@testing-library/react'; import React from 'react'; @@ -18,7 +19,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured const alertsContextCount = null; - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -29,12 +29,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -59,7 +59,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 0; // <-- no alerts to analyze - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; @@ -70,12 +69,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -104,8 +103,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const alertsCount = 10; - const attackDiscoveriesCount = 10; + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -115,12 +113,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={"you're a failure"} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -149,8 +147,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const alertsCount = 10; - const attackDiscoveriesCount = 10; + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const failureReason = 'this failure should NOT be displayed, because we are loading'; // <-- failureReason is provided const isLoading = true; // <-- loading data @@ -161,12 +158,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={failureReason} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -195,8 +192,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 0; // <-- no alerts contributed to attack discoveries - const attackDiscoveriesCount = 0; // <-- no attack discoveries were generated from the alerts + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -206,12 +202,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -240,7 +236,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = null; // <-- no connectors configured const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -251,12 +246,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -287,7 +282,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured (welcome prompt should be shown if not loading) const alertsContextCount = null; - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = true; // <-- loading data @@ -298,12 +292,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -338,8 +332,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 10; // <-- alerts contributed to attack discoveries - const attackDiscoveriesCount = 3; // <-- attack discoveries were generated from the alerts + const attackDiscoveriesCount = 7; // <-- attack discoveries are present const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -349,12 +342,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx index 49b4557c72192..a083ec7b77fdd 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx @@ -9,51 +9,55 @@ import React from 'react'; import { Failure } from '../failure'; import { EmptyPrompt } from '../empty_prompt'; -import { showEmptyPrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; +import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; import { NoAlerts } from '../no_alerts'; import { Welcome } from '../welcome'; interface Props { - aiConnectorsCount: number | null; - alertsContextCount: number | null; - alertsCount: number; + aiConnectorsCount: number | null; // null when connectors are not configured + alertsContextCount: number | null; // null when unavailable for the current connector attackDiscoveriesCount: number; connectorId: string | undefined; failureReason: string | null; isLoading: boolean; onGenerate: () => Promise<void>; + upToAlertsCount: number; } const EmptyStatesComponent: React.FC<Props> = ({ aiConnectorsCount, alertsContextCount, - alertsCount, attackDiscoveriesCount, connectorId, failureReason, isLoading, onGenerate, + upToAlertsCount, }) => { + const isDisabled = connectorId == null; + if (showWelcomePrompt({ aiConnectorsCount, isLoading })) { return <Welcome />; - } else if (!isLoading && failureReason != null) { + } + + if (showFailurePrompt({ connectorId, failureReason, isLoading })) { return <Failure failureReason={failureReason} />; - } else if (showNoAlertsPrompt({ alertsContextCount, isLoading })) { - return <NoAlerts />; - } else if (showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading })) { - return ( - <EmptyPrompt - alertsCount={alertsCount} - isDisabled={connectorId == null} - isLoading={isLoading} - onGenerate={onGenerate} - /> - ); } - return null; -}; + if (showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading })) { + return <NoAlerts isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; + } -EmptyStatesComponent.displayName = 'EmptyStates'; + return ( + <EmptyPrompt + aiConnectorsCount={aiConnectorsCount} + alertsCount={upToAlertsCount} + attackDiscoveriesCount={attackDiscoveriesCount} + isDisabled={isDisabled} + isLoading={isLoading} + onGenerate={onGenerate} + /> + ); +}; export const EmptyStates = React.memo(EmptyStatesComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx index 4318f3f78536a..c9c27446fe51c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx @@ -5,13 +5,53 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { + EuiAccordion, + EuiCodeBlock, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, +} from '@elastic/eui'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useMemo } from 'react'; import * as i18n from './translations'; -const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason }) => { +interface Props { + failureReason: string | null | undefined; +} + +const FailureComponent: React.FC<Props> = ({ failureReason }) => { + const Failures = useMemo(() => { + const failures = failureReason != null ? failureReason.split('\n') : ''; + const [firstFailure, ...restFailures] = failures; + + return ( + <> + <p>{firstFailure}</p> + + {restFailures.length > 0 && ( + <EuiAccordion + id="failuresFccordion" + buttonContent={i18n.DETAILS} + data-test-subj="failuresAccordion" + paddingSize="s" + > + <> + {restFailures.map((failure, i) => ( + <EuiCodeBlock fontSize="m" key={i} paddingSize="m"> + {failure} + </EuiCodeBlock> + ))} + </> + </EuiAccordion> + )} + </> + ); + }, [failureReason]); + return ( <EuiFlexGroup alignItems="center" data-test-subj="failure" direction="column"> <EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}> @@ -26,7 +66,7 @@ const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason } `} data-test-subj="bodyText" > - {failureReason} + {Failures} </EuiText> } title={<h2 data-test-subj="failureTitle">{i18n.FAILURE_TITLE}</h2>} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts index b36104d202ba8..ecaa7fad240e1 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const LEARN_MORE = i18n.translate( - 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', +export const DETAILS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.pages.failure.detailsAccordionButton', { - defaultMessage: 'Learn more about Attack discovery', + defaultMessage: 'Details', } ); @@ -20,3 +20,10 @@ export const FAILURE_TITLE = i18n.translate( defaultMessage: 'Attack discovery generation failed', } ); + +export const LEARN_MORE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', + { + defaultMessage: 'Learn more about Attack discovery', + } +); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx new file mode 100644 index 0000000000000..16ed376dd3af4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiToolTip } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../empty_prompt/translations'; + +interface Props { + isDisabled?: boolean; + isLoading: boolean; + onGenerate: () => void; +} + +const GenerateComponent: React.FC<Props> = ({ isLoading, isDisabled = false, onGenerate }) => { + const disabled = isLoading || isDisabled; + + return ( + <EuiToolTip + content={disabled ? i18n.SELECT_A_CONNECTOR : null} + data-test-subj="generateTooltip" + > + <EuiButton color="primary" data-test-subj="generate" disabled={disabled} onClick={onGenerate}> + {i18n.GENERATE} + </EuiButton> + </EuiToolTip> + ); +}; + +GenerateComponent.displayName = 'Generate'; + +export const Generate = React.memo(GenerateComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index aee53d889c7ac..7b0688eadafef 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; @@ -31,9 +32,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -54,9 +57,11 @@ describe('Header', () => { connectorsAreConfigured={connectorsAreConfigured} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -77,9 +82,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={onGenerate} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -102,9 +109,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -126,9 +135,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={onCancel} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -150,9 +161,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx index 583bcc25d0eb6..ff170805670a6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx @@ -9,10 +9,11 @@ import type { EuiButtonProps } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; +import { SettingsModal } from './settings_modal'; import { StatusBell } from './status_bell'; import * as i18n from './translations'; @@ -21,9 +22,11 @@ interface Props { connectorsAreConfigured: boolean; isLoading: boolean; isDisabledActions: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; onGenerate: () => void; onCancel: () => void; onConnectorIdSelected: (connectorId: string) => void; + setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; stats: AttackDiscoveryStats | null; } @@ -32,9 +35,11 @@ const HeaderComponent: React.FC<Props> = ({ connectorsAreConfigured, isLoading, isDisabledActions, + localStorageAttackDiscoveryMaxAlerts, onGenerate, onConnectorIdSelected, onCancel, + setLocalStorageAttackDiscoveryMaxAlerts, stats, }) => { const { euiTheme } = useEuiTheme(); @@ -68,6 +73,7 @@ const HeaderComponent: React.FC<Props> = ({ }, [isLoading, handleCancel, onGenerate] ); + return ( <EuiFlexGroup alignItems="center" @@ -78,6 +84,14 @@ const HeaderComponent: React.FC<Props> = ({ data-test-subj="header" gutterSize="none" > + <EuiFlexItem grow={false}> + <SettingsModal + connectorId={connectorId} + isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} + /> + </EuiFlexItem> <StatusBell stats={stats} /> {connectorsAreConfigured && ( <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx new file mode 100644 index 0000000000000..b51a1fc3f85c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SingleRangeChangeEvent } from '@kbn/elastic-assistant'; +import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { + AlertsRange, + SELECT_FEWER_ALERTS, + YOUR_ANONYMIZATION_SETTINGS, +} from '@kbn/elastic-assistant'; +import React, { useCallback } from 'react'; + +import * as i18n from '../translations'; + +export const MAX_ALERTS = 500; +export const MIN_ALERTS = 50; +export const ROW_MIN_WITH = 550; // px +export const STEP = 50; + +interface Props { + maxAlerts: string; + setMaxAlerts: React.Dispatch<React.SetStateAction<string>>; +} + +const AlertsSettingsComponent: React.FC<Props> = ({ maxAlerts, setMaxAlerts }) => { + const onChangeAlertsRange = useCallback( + (e: SingleRangeChangeEvent) => { + setMaxAlerts(e.currentTarget.value); + }, + [setMaxAlerts] + ); + + return ( + <EuiForm component="form"> + <EuiFormRow hasChildLabel={false} label={i18n.ALERTS}> + <EuiFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem grow={false}> + <AlertsRange + maxAlerts={MAX_ALERTS} + minAlerts={MIN_ALERTS} + onChange={onChangeAlertsRange} + step={STEP} + value={maxAlerts} + /> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{i18n.LATEST_AND_RISKIEST_OPEN_ALERTS(Number(maxAlerts))}</span> + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{YOUR_ANONYMIZATION_SETTINGS}</span> + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{SELECT_FEWER_ALERTS}</span> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> + </EuiForm> + ); +}; + +AlertsSettingsComponent.displayName = 'AlertsSettings'; + +export const AlertsSettings = React.memo(AlertsSettingsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx new file mode 100644 index 0000000000000..0066376a0e198 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +import * as i18n from '../translations'; + +interface Props { + closeModal: () => void; + onReset: () => void; + onSave: () => void; +} + +const FooterComponent: React.FC<Props> = ({ closeModal, onReset, onSave }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty data-test-sub="reset" flush="both" onClick={onReset} size="s"> + {i18n.RESET} + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem + css={css` + margin-right: ${euiTheme.size.s}; + `} + grow={false} + > + <EuiButtonEmpty data-test-sub="cancel" onClick={closeModal} size="s"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiButton data-test-sub="save" fill onClick={onSave} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +FooterComponent.displayName = 'Footer'; + +export const Footer = React.memo(FooterComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx new file mode 100644 index 0000000000000..7543985c74786 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonIcon, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EuiToolTip, + EuiTourStep, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { + ATTACK_DISCOVERY_STORAGE_KEY, + DEFAULT_ASSISTANT_NAMESPACE, + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, +} from '@kbn/elastic-assistant'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { AlertsSettings } from './alerts_settings'; +import { useSpaceId } from '../../../../common/hooks/use_space_id'; +import { Footer } from './footer'; +import { getIsTourEnabled } from './is_tour_enabled'; +import * as i18n from './translations'; + +interface Props { + connectorId: string | undefined; + isLoading: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; + setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; +} + +const SettingsModalComponent: React.FC<Props> = ({ + connectorId, + isLoading, + localStorageAttackDiscoveryMaxAlerts, + setLocalStorageAttackDiscoveryMaxAlerts, +}) => { + const spaceId = useSpaceId() ?? 'default'; + const modalTitleId = useGeneratedHtmlId(); + + const [maxAlerts, setMaxAlerts] = useState( + localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ); + + const [isModalVisible, setIsModalVisible] = useState(false); + const showModal = useCallback(() => { + setMaxAlerts(localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); + + setIsModalVisible(true); + }, [localStorageAttackDiscoveryMaxAlerts]); + const closeModal = useCallback(() => setIsModalVisible(false), []); + + const onReset = useCallback(() => setMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`), []); + + const onSave = useCallback(() => { + setLocalStorageAttackDiscoveryMaxAlerts(maxAlerts); + closeModal(); + }, [closeModal, maxAlerts, setLocalStorageAttackDiscoveryMaxAlerts]); + + const [showSettingsTour, setShowSettingsTour] = useLocalStorage<boolean>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY}.v8.16`, + true + ); + const onTourFinished = useCallback(() => setShowSettingsTour(() => false), [setShowSettingsTour]); + const [tourDelayElapsed, setTourDelayElapsed] = useState(false); + + useEffect(() => { + // visible EuiTourStep anchors don't follow the button when the layout changes (i.e. when the connectors finish loading) + const timeout = setTimeout(() => setTourDelayElapsed(true), 10000); + return () => clearTimeout(timeout); + }, []); + + const onSettingsClicked = useCallback(() => { + showModal(); + setShowSettingsTour(() => false); + }, [setShowSettingsTour, showModal]); + + const SettingsButton = useMemo( + () => ( + <EuiToolTip content={i18n.SETTINGS}> + <EuiButtonIcon + aria-label={i18n.SETTINGS} + data-test-subj="settings" + iconType="gear" + onClick={onSettingsClicked} + /> + </EuiToolTip> + ), + [onSettingsClicked] + ); + + const isTourEnabled = getIsTourEnabled({ + connectorId, + isLoading, + tourDelayElapsed, + showSettingsTour, + }); + + return ( + <> + {isTourEnabled ? ( + <EuiTourStep + anchorPosition="downCenter" + content={ + <> + <EuiText size="s"> + <p> + <span>{i18n.ATTACK_DISCOVERY_SENDS_MORE_ALERTS}</span> + <br /> + <span>{i18n.CONFIGURE_YOUR_SETTINGS_HERE}</span> + </p> + </EuiText> + </> + } + isStepOpen={showSettingsTour} + minWidth={300} + onFinish={onTourFinished} + step={1} + stepsTotal={1} + subtitle={i18n.RECENT_ATTACK_DISCOVERY_IMPROVEMENTS} + title={i18n.SEND_MORE_ALERTS} + > + {SettingsButton} + </EuiTourStep> + ) : ( + <>{SettingsButton}</> + )} + + {isModalVisible && ( + <EuiModal aria-labelledby={modalTitleId} data-test-subj="modal" onClose={closeModal}> + <EuiModalHeader> + <EuiModalHeaderTitle id={modalTitleId}>{i18n.SETTINGS}</EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody> + <AlertsSettings maxAlerts={maxAlerts} setMaxAlerts={setMaxAlerts} /> + </EuiModalBody> + + <EuiModalFooter> + <Footer closeModal={closeModal} onReset={onReset} onSave={onSave} /> + </EuiModalFooter> + </EuiModal> + )} + </> + ); +}; + +SettingsModalComponent.displayName = 'SettingsModal'; + +export const SettingsModal = React.memo(SettingsModalComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts new file mode 100644 index 0000000000000..7f2f356114902 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getIsTourEnabled = ({ + connectorId, + isLoading, + tourDelayElapsed, + showSettingsTour, +}: { + connectorId: string | undefined; + isLoading: boolean; + tourDelayElapsed: boolean; + showSettingsTour: boolean | undefined; +}): boolean => !isLoading && connectorId != null && tourDelayElapsed && !!showSettingsTour; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts new file mode 100644 index 0000000000000..dc42db84f2d8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.alertsLabel', + { + defaultMessage: 'Alerts', + } +); + +export const ATTACK_DISCOVERY_SENDS_MORE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.attackDiscoverySendsMoreAlertsTourText', + { + defaultMessage: 'Attack discovery sends more alerts as context.', + } +); + +export const CANCEL = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.cancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const CONFIGURE_YOUR_SETTINGS_HERE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.configureYourSettingsHereTourText', + { + defaultMessage: 'Configure your settings here.', + } +); + +export const LATEST_AND_RISKIEST_OPEN_ALERTS = (alertsCount: number) => + i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.latestAndRiskiestOpenAlertsLabel', + { + defaultMessage: + 'Send Attack discovery information about your {alertsCount} newest and riskiest open or acknowledged alerts.', + values: { alertsCount }, + } + ); + +export const SAVE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.saveButton', + { + defaultMessage: 'Save', + } +); + +export const SEND_MORE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.tourTitle', + { + defaultMessage: 'Send more alerts', + } +); + +export const SETTINGS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.settingsLabel', + { + defaultMessage: 'Settings', + } +); + +export const RECENT_ATTACK_DISCOVERY_IMPROVEMENTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.tourSubtitle', + { + defaultMessage: 'Recent Attack discovery improvements', + } +); + +export const RESET = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.resetLabel', + { + defaultMessage: 'Reset', + } +); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts index e94687611ea8f..c7e1c579418b4 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts @@ -12,6 +12,7 @@ describe('helpers', () => { it('returns true when isLoading is false and alertsContextCount is 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, + connectorId: 'test', isLoading: false, }); @@ -21,6 +22,7 @@ describe('helpers', () => { it('returns false when isLoading is true', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, + connectorId: 'test', isLoading: true, }); @@ -30,6 +32,7 @@ describe('helpers', () => { it('returns false when alertsContextCount is null', () => { const result = showNoAlertsPrompt({ alertsContextCount: null, + connectorId: 'test', isLoading: false, }); @@ -39,6 +42,7 @@ describe('helpers', () => { it('returns false when alertsContextCount greater than 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 20, + connectorId: 'test', isLoading: false, }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts index e3d3be963bacd..b990c3ccf1555 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts @@ -75,11 +75,14 @@ export const getErrorToastText = ( export const showNoAlertsPrompt = ({ alertsContextCount, + connectorId, isLoading, }: { alertsContextCount: number | null; + connectorId: string | undefined; isLoading: boolean; -}): boolean => !isLoading && alertsContextCount != null && alertsContextCount === 0; +}): boolean => + connectorId != null && !isLoading && alertsContextCount != null && alertsContextCount === 0; export const showWelcomePrompt = ({ aiConnectorsCount, @@ -111,12 +114,26 @@ export const showLoading = ({ loadingConnectorId: string | null; }): boolean => isLoading && (loadingConnectorId === connectorId || attackDiscoveriesCount === 0); -export const showSummary = ({ +export const showSummary = (attackDiscoveriesCount: number) => attackDiscoveriesCount > 0; + +export const showFailurePrompt = ({ connectorId, - attackDiscoveriesCount, - loadingConnectorId, + failureReason, + isLoading, }: { connectorId: string | undefined; - attackDiscoveriesCount: number; - loadingConnectorId: string | null; -}): boolean => loadingConnectorId !== connectorId && attackDiscoveriesCount > 0; + failureReason: string | null; + isLoading: boolean; +}): boolean => connectorId != null && !isLoading && failureReason != null; + +export const getSize = ({ + defaultMaxAlerts, + localStorageAttackDiscoveryMaxAlerts, +}: { + defaultMaxAlerts: number; + localStorageAttackDiscoveryMaxAlerts: string | undefined; +}): number => { + const size = Number(localStorageAttackDiscoveryMaxAlerts); + + return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index ea5c16fc3cbba..e55b2fe5083b6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -5,11 +5,13 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; import { css } from '@emotion/react'; import { ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + MAX_ALERTS_LOCAL_STORAGE_KEY, useAssistantContext, useLoadConnectors, } from '@kbn/elastic-assistant'; @@ -23,23 +25,16 @@ import { HeaderPage } from '../../common/components/header_page'; import { useSpaceId } from '../../common/hooks/use_space_id'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Header } from './header'; -import { - CONNECTOR_ID_LOCAL_STORAGE_KEY, - getInitialIsOpen, - showLoading, - showSummary, -} from './helpers'; -import { AttackDiscoveryPanel } from '../attack_discovery_panel'; -import { EmptyStates } from './empty_states'; +import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers'; import { LoadingCallout } from './loading_callout'; import { PageTitle } from './page_title'; -import { Summary } from './summary'; +import { Results } from './results'; import { useAttackDiscovery } from '../use_attack_discovery'; const AttackDiscoveryPageComponent: React.FC = () => { const spaceId = useSpaceId() ?? 'default'; - const { http, knowledgeBase } = useAssistantContext(); + const { http } = useAssistantContext(); const { data: aiConnectors } = useLoadConnectors({ http, }); @@ -54,6 +49,12 @@ const AttackDiscoveryPageComponent: React.FC = () => { `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${CONNECTOR_ID_LOCAL_STORAGE_KEY}` ); + const [localStorageAttackDiscoveryMaxAlerts, setLocalStorageAttackDiscoveryMaxAlerts] = + useLocalStorage<string>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${MAX_ALERTS_LOCAL_STORAGE_KEY}`, + `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ); + const [connectorId, setConnectorId] = React.useState<string | undefined>( localStorageAttackDiscoveryConnectorId ); @@ -78,6 +79,10 @@ const AttackDiscoveryPageComponent: React.FC = () => { } = useAttackDiscovery({ connectorId, setLoadingConnectorId, + size: getSize({ + defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + localStorageAttackDiscoveryMaxAlerts, + }), }); // get last updated from the cached attack discoveries if it exists: @@ -159,9 +164,11 @@ const AttackDiscoveryPageComponent: React.FC = () => { isLoading={isLoading} // disable header actions before post request has completed isDisabledActions={isLoadingPost} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} onConnectorIdSelected={onConnectorIdSelected} onGenerate={onGenerate} onCancel={onCancel} + setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} stats={stats} /> <EuiSpacer size="m" /> @@ -170,68 +177,37 @@ const AttackDiscoveryPageComponent: React.FC = () => { <EuiEmptyPrompt data-test-subj="animatedLogo" icon={animatedLogo} /> ) : ( <> - {showSummary({ + {showLoading({ attackDiscoveriesCount, connectorId, + isLoading: isLoading || isLoadingPost, loadingConnectorId, - }) && ( - <Summary + }) ? ( + <LoadingCallout + alertsContextCount={alertsContextCount} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + approximateFutureTime={approximateFutureTime} + connectorIntervals={connectorIntervals} + /> + ) : ( + <Results + aiConnectorsCount={aiConnectors?.length ?? null} + alertsContextCount={alertsContextCount} alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} - lastUpdated={selectedConnectorLastUpdated} + connectorId={connectorId} + failureReason={failureReason} + isLoading={isLoading} + isLoadingPost={isLoadingPost} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + onGenerate={onGenerate} onToggleShowAnonymized={onToggleShowAnonymized} + selectedConnectorAttackDiscoveries={selectedConnectorAttackDiscoveries} + selectedConnectorLastUpdated={selectedConnectorLastUpdated} + selectedConnectorReplacements={selectedConnectorReplacements} showAnonymized={showAnonymized} /> )} - - <> - {showLoading({ - attackDiscoveriesCount, - connectorId, - isLoading: isLoading || isLoadingPost, - loadingConnectorId, - }) ? ( - <LoadingCallout - alertsCount={knowledgeBase.latestAlerts} - approximateFutureTime={approximateFutureTime} - connectorIntervals={connectorIntervals} - /> - ) : ( - selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( - <React.Fragment key={attackDiscovery.id}> - <AttackDiscoveryPanel - attackDiscovery={attackDiscovery} - initialIsOpen={getInitialIsOpen(i)} - showAnonymized={showAnonymized} - replacements={selectedConnectorReplacements} - /> - <EuiSpacer size="l" /> - </React.Fragment> - )) - )} - </> - <EuiFlexGroup - css={css` - max-height: 100%; - min-height: 100%; - `} - direction="column" - gutterSize="none" - > - <EuiSpacer size="xxl" /> - <EuiFlexItem grow={false}> - <EmptyStates - aiConnectorsCount={aiConnectors?.length ?? null} - alertsContextCount={alertsContextCount} - alertsCount={knowledgeBase.latestAlerts} - attackDiscoveriesCount={attackDiscoveriesCount} - failureReason={failureReason} - connectorId={connectorId} - isLoading={isLoading || isLoadingPost} - onGenerate={onGenerate} - /> - </EuiFlexItem> - </EuiFlexGroup> </> )} <SpyRoute pageName={SecurityPageName.attackDiscovery} /> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx index af6efafb3c1dd..f755017288300 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx @@ -29,9 +29,10 @@ describe('LoadingCallout', () => { ]; const defaultProps = { - alertsCount: 30, + alertsContextCount: 30, approximateFutureTime: new Date(), connectorIntervals, + localStorageAttackDiscoveryMaxAlerts: '50', }; it('renders the animated loading icon', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx index 7e392e3165711..aee8241ec73fc 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx @@ -20,13 +20,15 @@ const BACKGROUND_COLOR_DARK = '#0B2030'; const BORDER_COLOR_DARK = '#0B2030'; interface Props { - alertsCount: number; + alertsContextCount: number | null; approximateFutureTime: Date | null; connectorIntervals: GenerationInterval[]; + localStorageAttackDiscoveryMaxAlerts: string | undefined; } const LoadingCalloutComponent: React.FC<Props> = ({ - alertsCount, + alertsContextCount, + localStorageAttackDiscoveryMaxAlerts, approximateFutureTime, connectorIntervals, }) => { @@ -46,11 +48,14 @@ const LoadingCalloutComponent: React.FC<Props> = ({ `} grow={false} > - <LoadingMessages alertsCount={alertsCount} /> + <LoadingMessages + alertsContextCount={alertsContextCount} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + /> </EuiFlexItem> </EuiFlexGroup> ), - [alertsCount, euiTheme.size.m] + [alertsContextCount, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts] ); const isDarkMode = theme.getTheme().darkMode === true; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts new file mode 100644 index 0000000000000..9a3061272ca15 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getLoadingCalloutAlertsCount = ({ + alertsContextCount, + defaultMaxAlerts, + localStorageAttackDiscoveryMaxAlerts, +}: { + alertsContextCount: number | null; + defaultMaxAlerts: number; + localStorageAttackDiscoveryMaxAlerts: string | undefined; +}): number => { + if (alertsContextCount != null && !isNaN(alertsContextCount) && alertsContextCount > 0) { + return alertsContextCount; + } + + const size = Number(localStorageAttackDiscoveryMaxAlerts); + + return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx index 250a25055791a..8b3f174792c5e 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx @@ -16,7 +16,7 @@ describe('LoadingMessages', () => { it('renders the expected loading message', () => { render( <TestProviders> - <LoadingMessages alertsCount={20} /> + <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> </TestProviders> ); const attackDiscoveryGenerationInProgress = screen.getByTestId( @@ -31,7 +31,7 @@ describe('LoadingMessages', () => { it('renders the loading message with the expected alerts count', () => { render( <TestProviders> - <LoadingMessages alertsCount={20} /> + <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> </TestProviders> ); const aiCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing'); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx index 9acd7b4d2dbbf..1a84771e5c635 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx @@ -7,22 +7,34 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import React from 'react'; import { useKibana } from '../../../../common/lib/kibana'; +import { getLoadingCalloutAlertsCount } from './get_loading_callout_alerts_count'; import * as i18n from '../translations'; const TEXT_COLOR = '#343741'; interface Props { - alertsCount: number; + alertsContextCount: number | null; + localStorageAttackDiscoveryMaxAlerts: string | undefined; } -const LoadingMessagesComponent: React.FC<Props> = ({ alertsCount }) => { +const LoadingMessagesComponent: React.FC<Props> = ({ + alertsContextCount, + localStorageAttackDiscoveryMaxAlerts, +}) => { const { theme } = useKibana().services; const isDarkMode = theme.getTheme().darkMode === true; + const alertsCount = getLoadingCalloutAlertsCount({ + alertsContextCount, + defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + localStorageAttackDiscoveryMaxAlerts, + }); + return ( <EuiFlexGroup data-test-subj="loadingMessages" direction="column" gutterSize="none"> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx index 6c2640623e370..6c6bbfb25cb7f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx @@ -13,7 +13,7 @@ import { ATTACK_DISCOVERY_ONLY, LEARN_MORE, NO_ALERTS_TO_ANALYZE } from './trans describe('NoAlerts', () => { beforeEach(() => { - render(<NoAlerts />); + render(<NoAlerts isDisabled={false} isLoading={false} onGenerate={jest.fn()} />); }); it('renders the avatar', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx index a7b0cd929336b..ace75f568bf3d 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx @@ -17,8 +17,15 @@ import { import React, { useMemo } from 'react'; import * as i18n from './translations'; +import { Generate } from '../generate'; -const NoAlertsComponent: React.FC = () => { +interface Props { + isDisabled: boolean; + isLoading: boolean; + onGenerate: () => void; +} + +const NoAlertsComponent: React.FC<Props> = ({ isDisabled, isLoading, onGenerate }) => { const title = useMemo( () => ( <EuiFlexGroup @@ -83,6 +90,14 @@ const NoAlertsComponent: React.FC = () => { {i18n.LEARN_MORE} </EuiLink> </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <Generate isDisabled={isDisabled} isLoading={isLoading} onGenerate={onGenerate} /> + </EuiFlexItem> </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx new file mode 100644 index 0000000000000..6e3e43127e711 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSpacer } from '@elastic/eui'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; +import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import React from 'react'; + +import { AttackDiscoveryPanel } from '../../attack_discovery_panel'; +import { EmptyStates } from '../empty_states'; +import { showEmptyStates } from '../empty_states/helpers/show_empty_states'; +import { getInitialIsOpen, showSummary } from '../helpers'; +import { Summary } from '../summary'; + +interface Props { + aiConnectorsCount: number | null; // null when connectors are not configured + alertsContextCount: number | null; // null when unavailable for the current connector + alertsCount: number; + attackDiscoveriesCount: number; + connectorId: string | undefined; + failureReason: string | null; + isLoading: boolean; + isLoadingPost: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; + onGenerate: () => Promise<void>; + onToggleShowAnonymized: () => void; + selectedConnectorAttackDiscoveries: AttackDiscovery[]; + selectedConnectorLastUpdated: Date | null; + selectedConnectorReplacements: Replacements; + showAnonymized: boolean; +} + +const ResultsComponent: React.FC<Props> = ({ + aiConnectorsCount, + alertsContextCount, + alertsCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, + isLoadingPost, + localStorageAttackDiscoveryMaxAlerts, + onGenerate, + onToggleShowAnonymized, + selectedConnectorAttackDiscoveries, + selectedConnectorLastUpdated, + selectedConnectorReplacements, + showAnonymized, +}) => { + if ( + showEmptyStates({ + aiConnectorsCount, + alertsContextCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, + }) + ) { + return ( + <> + <EuiSpacer size="xxl" /> + <EmptyStates + aiConnectorsCount={aiConnectorsCount} + alertsContextCount={alertsContextCount} + attackDiscoveriesCount={attackDiscoveriesCount} + failureReason={failureReason} + connectorId={connectorId} + isLoading={isLoading || isLoadingPost} + onGenerate={onGenerate} + upToAlertsCount={Number( + localStorageAttackDiscoveryMaxAlerts ?? DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS + )} + /> + </> + ); + } + + return ( + <> + {showSummary(attackDiscoveriesCount) && ( + <Summary + alertsCount={alertsCount} + attackDiscoveriesCount={attackDiscoveriesCount} + lastUpdated={selectedConnectorLastUpdated} + onToggleShowAnonymized={onToggleShowAnonymized} + showAnonymized={showAnonymized} + /> + )} + + {selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( + <React.Fragment key={attackDiscovery.id}> + <AttackDiscoveryPanel + attackDiscovery={attackDiscovery} + initialIsOpen={getInitialIsOpen(i)} + showAnonymized={showAnonymized} + replacements={selectedConnectorReplacements} + /> + <EuiSpacer size="l" /> + </React.Fragment> + ))} + </> + ); +}; + +ResultsComponent.displayName = 'Results'; + +export const Results = React.memo(ResultsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts index f2fd17d5978b7..cc0034c90d1fa 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { omit } from 'lodash/fp'; @@ -132,9 +133,7 @@ describe('getRequestBody', () => { }, ], }; - const knowledgeBase = { - latestAlerts: 20, - }; + const traceOptions = { apmUrl: '/app/apm', langSmithProject: '', @@ -145,7 +144,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -160,8 +159,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -170,7 +169,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern: undefined, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -185,8 +184,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -195,7 +194,7 @@ describe('getRequestBody', () => { const withLangSmith = { alertsIndexPattern, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions: { apmUrl: '/app/apm', langSmithProject: 'A project', @@ -216,7 +215,7 @@ describe('getRequestBody', () => { }, langSmithApiKey: withLangSmith.traceOptions.langSmithApiKey, langSmithProject: withLangSmith.traceOptions.langSmithProject, - size: knowledgeBase.latestAlerts, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, replacements: {}, subAction: 'invokeAI', }); @@ -226,8 +225,8 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, - knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -242,7 +241,7 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, replacements: {}, subAction: 'invokeAI', }); @@ -258,8 +257,8 @@ describe('getRequestBody', () => { alertsIndexPattern, anonymizationFields, genAiConfig, // <-- genAiConfig is provided - knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -274,8 +273,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts index 97eb132bdaaeb..7aa9bfdd118d9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts @@ -5,10 +5,7 @@ * 2.0. */ -import type { - KnowledgeBaseConfig, - TraceOptions, -} from '@kbn/elastic-assistant/impl/assistant/types'; +import type { TraceOptions } from '@kbn/elastic-assistant/impl/assistant/types'; import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import type { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -60,8 +57,8 @@ export const getRequestBody = ({ alertsIndexPattern, anonymizationFields, genAiConfig, - knowledgeBase, selectedConnector, + size, traceOptions, }: { alertsIndexPattern: string | undefined; @@ -83,7 +80,7 @@ export const getRequestBody = ({ }>; }; genAiConfig?: GenAiConfig; - knowledgeBase: KnowledgeBaseConfig; + size: number; selectedConnector?: ActionConnector; traceOptions: TraceOptions; }): AttackDiscoveryPostRequestBody => ({ @@ -95,8 +92,8 @@ export const getRequestBody = ({ langSmithApiKey: isEmpty(traceOptions?.langSmithApiKey) ? undefined : traceOptions?.langSmithApiKey, - size: knowledgeBase.latestAlerts, replacements: {}, // no need to re-use replacements in the current implementation + size, subAction: 'invokeAI', // non-streaming apiConfig: { connectorId: selectedConnector?.id ?? '', diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx index 6329ce5ca699a..59659ee6d8649 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx @@ -106,6 +106,8 @@ const mockAttackDiscoveries = [ const setLoadingConnectorId = jest.fn(); const setStatus = jest.fn(); +const SIZE = 20; + describe('useAttackDiscovery', () => { const mockPollApi = { cancelAttackDiscovery: jest.fn(), @@ -126,7 +128,11 @@ describe('useAttackDiscovery', () => { it('initializes with correct default values', () => { const { result } = renderHook(() => - useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: 20, + }) ); expect(result.current.alertsContextCount).toBeNull(); @@ -144,14 +150,15 @@ describe('useAttackDiscovery', () => { it('fetches attack discoveries and updates state correctly', async () => { (mockedUseKibana.services.http.fetch as jest.Mock).mockResolvedValue(mockAttackDiscoveryPost); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); + await act(async () => { await result.current.fetchAttackDiscoveries(); }); expect(mockedUseKibana.services.http.fetch).toHaveBeenCalledWith( '/internal/elastic_assistant/attack_discovery', { - body: '{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"size":20,"replacements":{},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}', + body: `{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"replacements":{},"size":${SIZE},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}`, method: 'POST', version: '1', } @@ -167,7 +174,7 @@ describe('useAttackDiscovery', () => { const error = new Error(errorMessage); (mockedUseKibana.services.http.fetch as jest.Mock).mockRejectedValue(error); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); await act(async () => { await result.current.fetchAttackDiscoveries(); @@ -184,7 +191,11 @@ describe('useAttackDiscovery', () => { it('sets loading state based on poll status', async () => { (usePollApi as jest.Mock).mockReturnValue({ ...mockPollApi, status: 'running' }); const { result } = renderHook(() => - useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: SIZE, + }) ); expect(result.current.isLoading).toBe(true); @@ -202,7 +213,7 @@ describe('useAttackDiscovery', () => { }, status: 'succeeded', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); expect(result.current.alertsContextCount).toEqual(20); // this is set from usePollApi @@ -227,7 +238,7 @@ describe('useAttackDiscovery', () => { }, status: 'failed', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); expect(result.current.failureReason).toEqual('something bad'); expect(result.current.isLoading).toBe(false); @@ -241,7 +252,13 @@ describe('useAttackDiscovery', () => { data: [], // <-- zero connectors configured }); - renderHook(() => useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId })); + renderHook(() => + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: SIZE, + }) + ); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx index deb1c556bdb43..4ad78981d4540 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx @@ -43,9 +43,11 @@ export interface UseAttackDiscovery { export const useAttackDiscovery = ({ connectorId, + size, setLoadingConnectorId, }: { connectorId: string | undefined; + size: number; setLoadingConnectorId?: (loadingConnectorId: string | null) => void; }): UseAttackDiscovery => { // get Kibana services and connectors @@ -75,7 +77,7 @@ export const useAttackDiscovery = ({ const [isLoading, setIsLoading] = useState(false); // get alerts index pattern and allow lists from the assistant context: - const { alertsIndexPattern, knowledgeBase, traceOptions } = useAssistantContext(); + const { alertsIndexPattern, traceOptions } = useAssistantContext(); const { data: anonymizationFields } = useFetchAnonymizationFields(); @@ -95,18 +97,11 @@ export const useAttackDiscovery = ({ alertsIndexPattern, anonymizationFields, genAiConfig, - knowledgeBase, + size, selectedConnector, traceOptions, }); - }, [ - aiConnectors, - alertsIndexPattern, - anonymizationFields, - connectorId, - knowledgeBase, - traceOptions, - ]); + }, [aiConnectors, alertsIndexPattern, anonymizationFields, connectorId, size, traceOptions]); useEffect(() => { if ( @@ -140,7 +135,7 @@ export const useAttackDiscovery = ({ useEffect(() => { if (pollData !== null && pollData.connectorId === connectorId) { if (pollData.alertsContextCount != null) setAlertsContextCount(pollData.alertsContextCount); - if (pollData.attackDiscoveries.length) { + if (pollData.attackDiscoveries.length && pollData.attackDiscoveries[0].timestamp != null) { // get last updated from timestamp, not from updatedAt since this can indicate the last time the status was updated setLastUpdated(new Date(pollData.attackDiscoveries[0].timestamp)); } diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts deleted file mode 100644 index 4d06751f57d7d..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import type { KibanaRequest } from '@kbn/core-http-server'; -import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import type { DynamicTool } from '@langchain/core/tools'; - -import { loggerMock } from '@kbn/logging-mocks'; - -import { ATTACK_DISCOVERY_TOOL } from './attack_discovery_tool'; -import { mockAnonymizationFields } from '../mock/mock_anonymization_fields'; -import { mockEmptyOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_empty_open_and_acknowledged_alerts_qery_results'; -import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; - -jest.mock('langchain/chains', () => { - const mockLLMChain = jest.fn().mockImplementation(() => ({ - call: jest.fn().mockResolvedValue({ - records: [ - { - alertIds: [ - 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', - '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', - '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', - 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', - '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', - ], - detailsMarkdown: - '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', - entitySummaryMarkdown: - 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - mitreAttackTactics: ['Credential Access', 'Execution'], - summaryMarkdown: - 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - title: 'Malware Delivering Malicious Payloads on macOS', - }, - ], - }), - })); - - return { - LLMChain: mockLLMChain, - }; -}); - -describe('AttackDiscoveryTool', () => { - const alertsIndexPattern = '.alerts-security.alerts-default'; - const replacements = { uuid: 'original_value' }; - const size = 20; - const request = { - body: { - actionTypeId: '.bedrock', - alertsIndexPattern, - anonymizationFields: mockAnonymizationFields, - connectorId: 'test-connector-id', - replacements, - size, - subAction: 'invokeAI', - }, - } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; - - const esClient = { - search: jest.fn(), - } as unknown as ElasticsearchClient; - const llm = jest.fn() as unknown as ActionsClientLlm; - const logger = loggerMock.create(); - - const rest = { - anonymizationFields: mockAnonymizationFields, - isEnabledKnowledgeBase: false, - llm, - logger, - onNewReplacements: jest.fn(), - size, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - (esClient.search as jest.Mock).mockResolvedValue(mockOpenAndAcknowledgedAlertsQueryResults); - }); - - describe('isSupported', () => { - it('returns false when the request is missing required anonymization parameters', () => { - const requestMissingAnonymizationParams = { - body: { - isEnabledKnowledgeBase: false, - alertsIndexPattern: '.alerts-security.alerts-default', - size: 20, - }, - } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; - - const params = { - alertsIndexPattern, - esClient, - request: requestMissingAnonymizationParams, // <-- request is missing required anonymization parameters - ...rest, - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when the alertsIndexPattern is undefined', () => { - const params = { - esClient, - request, - ...rest, - alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when size is undefined', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - size: undefined, // <-- size is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when size is out of range', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - size: 0, // <-- size is out of range - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when llm is undefined', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - llm: undefined, // <-- llm is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns true if all required params are provided', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(true); - }); - }); - - describe('getTool', () => { - it('returns null when llm is undefined', () => { - const tool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - llm: undefined, // <-- llm is undefined - }); - - expect(tool).toBeNull(); - }); - - it('returns a `DynamicTool` with a `func` that calls `esClient.search()` with the expected alerts query', async () => { - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - await tool.func(''); - - expect(esClient.search).toHaveBeenCalledWith({ - allow_no_indices: true, - body: { - _source: false, - fields: mockAnonymizationFields.map(({ field }) => ({ - field, - include_unmapped: true, - })), - query: { - bool: { - filter: [ - { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'kibana.alert.workflow_status': 'open', - }, - }, - { - match_phrase: { - 'kibana.alert.workflow_status': 'acknowledged', - }, - }, - ], - }, - }, - { - range: { - '@timestamp': { - format: 'strict_date_optional_time', - gte: 'now-24h', - lte: 'now', - }, - }, - }, - ], - must: [], - must_not: [ - { - exists: { - field: 'kibana.alert.building_block_type', - }, - }, - ], - should: [], - }, - }, - ], - }, - }, - runtime_mappings: {}, - size, - sort: [ - { - 'kibana.alert.risk_score': { - order: 'desc', - }, - }, - { - '@timestamp': { - order: 'desc', - }, - }, - ], - }, - ignore_unavailable: true, - index: [alertsIndexPattern], - }); - }); - - it('returns a `DynamicTool` with a `func` returns an empty attack discoveries array when getAnonymizedAlerts returns no alerts', async () => { - (esClient.search as jest.Mock).mockResolvedValue( - mockEmptyOpenAndAcknowledgedAlertsQueryResults // <-- no alerts - ); - - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - const result = await tool.func(''); - const expected = JSON.stringify({ alertsContextCount: 0, attackDiscoveries: [] }, null, 2); // <-- empty attack discoveries array - - expect(result).toEqual(expected); - }); - - it('returns a `DynamicTool` with a `func` that returns the expected results', async () => { - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - await tool.func(''); - - const result = await tool.func(''); - const expected = JSON.stringify( - { - alertsContextCount: 20, - attackDiscoveries: [ - { - alertIds: [ - 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', - '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', - '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', - 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', - '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', - ], - detailsMarkdown: - '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', - entitySummaryMarkdown: - 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - mitreAttackTactics: ['Credential Access', 'Execution'], - summaryMarkdown: - 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - title: 'Malware Delivering Malicious Payloads on macOS', - }, - ], - }, - null, - 2 - ); - - expect(result).toEqual(expected); - }); - - it('returns a tool instance with the expected tags', () => { - const tool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - expect(tool.tags).toEqual(['attack-discovery']); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts deleted file mode 100644 index 264862d76b8f5..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PromptTemplate } from '@langchain/core/prompts'; -import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; -import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; -import { LLMChain } from 'langchain/chains'; -import { OutputFixingParser } from 'langchain/output_parsers'; -import { DynamicTool } from '@langchain/core/tools'; - -import { APP_UI_ID } from '../../../../common'; -import { getAnonymizedAlerts } from './get_anonymized_alerts'; -import { getOutputParser } from './get_output_parser'; -import { sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; -import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; - -export interface AttackDiscoveryToolParams extends AssistantToolParams { - alertsIndexPattern: string; - size: number; -} - -export const ATTACK_DISCOVERY_TOOL_DESCRIPTION = - 'Call this for attack discoveries containing `markdown` that should be displayed verbatim (with no additional processing).'; - -/** - * Returns a tool for generating attack discoveries from open and acknowledged - * alerts, or null if the request doesn't have all the required parameters. - */ -export const ATTACK_DISCOVERY_TOOL: AssistantTool = { - id: 'attack-discovery', - name: 'AttackDiscoveryTool', - description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, - sourceRegister: APP_UI_ID, - isSupported: (params: AssistantToolParams): params is AttackDiscoveryToolParams => { - const { alertsIndexPattern, llm, request, size } = params; - - return ( - requestHasRequiredAnonymizationParams(request) && - alertsIndexPattern != null && - size != null && - !sizeIsOutOfRange(size) && - llm != null - ); - }, - getTool(params: AssistantToolParams) { - if (!this.isSupported(params)) return null; - - const { - alertsIndexPattern, - anonymizationFields, - esClient, - langChainTimeout, - llm, - onNewReplacements, - replacements, - size, - } = params as AttackDiscoveryToolParams; - - return new DynamicTool({ - name: 'AttackDiscoveryTool', - description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, - func: async () => { - if (llm == null) { - throw new Error('LLM is required for attack discoveries'); - } - - const anonymizedAlerts = await getAnonymizedAlerts({ - alertsIndexPattern, - anonymizationFields, - esClient, - onNewReplacements, - replacements, - size, - }); - - const alertsContextCount = anonymizedAlerts.length; - if (alertsContextCount === 0) { - // No alerts to analyze, so return an empty attack discoveries array - return JSON.stringify({ alertsContextCount, attackDiscoveries: [] }, null, 2); - } - - const outputParser = getOutputParser(); - const outputFixingParser = OutputFixingParser.fromLLM(llm, outputParser); - - const prompt = new PromptTemplate({ - template: `Answer the user's question as best you can:\n{format_instructions}\n{query}`, - inputVariables: ['query'], - partialVariables: { - format_instructions: outputFixingParser.getFormatInstructions(), - }, - }); - - const answerFormattingChain = new LLMChain({ - llm, - prompt, - outputKey: 'records', - outputParser: outputFixingParser, - }); - - const result = await answerFormattingChain.call({ - query: getAttackDiscoveryPrompt({ anonymizedAlerts }), - timeout: langChainTimeout, - }); - const attackDiscoveries = result.records; - - return JSON.stringify({ alertsContextCount, attackDiscoveries }, null, 2); - }, - tags: ['attack-discovery'], - }); - }, -}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts deleted file mode 100644 index df211f0bd0a7d..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. -export const getAttackDiscoveryPrompt = ({ - anonymizedAlerts, -}: { - anonymizedAlerts: string[]; -}) => `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. - -Use context from the following open and acknowledged alerts to provide insights: - -""" -${anonymizedAlerts.join('\n\n')} -""" -`; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts deleted file mode 100644 index 5ad2cd11f817a..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { getOutputParser } from './get_output_parser'; - -describe('getOutputParser', () => { - it('returns a structured output parser with the expected format instructions', () => { - const outputParser = getOutputParser(); - - const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. - -\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. - -For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} -would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. -Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. - -Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! - -Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: -\`\`\`json -{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\",\"$schema\":\"http://json-schema.org/draft-07/schema#\"} -\`\`\` -`; - - expect(outputParser.getFormatInstructions()).toEqual(expected); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts deleted file mode 100644 index 3d66257f060e4..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { StructuredOutputParser } from 'langchain/output_parsers'; -import { z } from '@kbn/zod'; - -export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; -const GOOD_SYNTAX_EXAMPLES = - 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; - -const BAD_SYNTAX_EXAMPLES = - 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; - -const RECONNAISSANCE = 'Reconnaissance'; -const INITIAL_ACCESS = 'Initial Access'; -const EXECUTION = 'Execution'; -const PERSISTENCE = 'Persistence'; -const PRIVILEGE_ESCALATION = 'Privilege Escalation'; -const DISCOVERY = 'Discovery'; -const LATERAL_MOVEMENT = 'Lateral Movement'; -const COMMAND_AND_CONTROL = 'Command and Control'; -const EXFILTRATION = 'Exfiltration'; - -const MITRE_ATTACK_TACTICS = [ - RECONNAISSANCE, - INITIAL_ACCESS, - EXECUTION, - PERSISTENCE, - PRIVILEGE_ESCALATION, - DISCOVERY, - LATERAL_MOVEMENT, - COMMAND_AND_CONTROL, - EXFILTRATION, -] as const; - -// NOTE: we ask the LLM for `insight`s. We do NOT use the feature name, `AttackDiscovery`, in the prompt. -export const getOutputParser = () => - StructuredOutputParser.fromZodSchema( - z - .array( - z.object({ - alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), - detailsMarkdown: z - .string() - .describe( - `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ), - entitySummaryMarkdown: z - .string() - .optional() - .describe( - `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` - ), - mitreAttackTactics: z - .string() - .array() - .optional() - .describe( - `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( - ',' - )}` - ), - summaryMarkdown: z - .string() - .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), - title: z - .string() - .describe( - 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' - ), - }) - ) - .describe( - `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ) - ); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/index.ts index a704aaa44d0a1..1b6e90eb7280f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/index.ts @@ -10,7 +10,6 @@ import type { AssistantTool } from '@kbn/elastic-assistant-plugin/server'; import { NL_TO_ESQL_TOOL } from './esql/nl_to_esql_tool'; import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool'; -import { ATTACK_DISCOVERY_TOOL } from './attack_discovery/attack_discovery_tool'; import { KNOWLEDGE_BASE_RETRIEVAL_TOOL } from './knowledge_base/knowledge_base_retrieval_tool'; import { KNOWLEDGE_BASE_WRITE_TOOL } from './knowledge_base/knowledge_base_write_tool'; import { SECURITY_LABS_KNOWLEDGE_BASE_TOOL } from './security_labs/security_labs_tool'; @@ -22,7 +21,6 @@ export const getAssistantTools = ({ }): AssistantTool[] => { const tools = [ ALERT_COUNTS_TOOL, - ATTACK_DISCOVERY_TOOL, NL_TO_ESQL_TOOL, KNOWLEDGE_BASE_RETRIEVAL_TOOL, KNOWLEDGE_BASE_WRITE_TOOL, diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts deleted file mode 100644 index 722936a368b36..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - getRawDataOrDefault, - isRawDataValid, - MAX_SIZE, - MIN_SIZE, - sizeIsOutOfRange, -} from './helpers'; - -describe('helpers', () => { - describe('isRawDataValid', () => { - it('returns true for valid raw data', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns true when a field array is empty', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - field3: [], // the Fields API may return an empty array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when a field does not have an array of values', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(isRawDataValid(rawData)).toBe(false); - }); - - it('returns true for empty raw data', () => { - const rawData = {}; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when raw data is an unexpected type', () => { - const rawData = 1234; - - // @ts-expect-error - expect(isRawDataValid(rawData)).toBe(false); - }); - }); - - describe('getRawDataOrDefault', () => { - it('returns the raw data when it is valid', () => { - const rawData = { - field1: [1, 2, 3], - field2: ['a', 'b', 'c'], - }; - - expect(getRawDataOrDefault(rawData)).toEqual(rawData); - }); - - it('returns an empty object when the raw data is invalid', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(getRawDataOrDefault(rawData)).toEqual({}); - }); - }); - - describe('sizeIsOutOfRange', () => { - it('returns true when size is undefined', () => { - const size = undefined; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is less than MIN_SIZE', () => { - const size = MIN_SIZE - 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is greater than MAX_SIZE', () => { - const size = MAX_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns false when size is exactly MIN_SIZE', () => { - const size = MIN_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is exactly MAX_SIZE', () => { - const size = MAX_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is within the valid range', () => { - const size = MIN_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts deleted file mode 100644 index dcb30e04e9dbc..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -export const MIN_SIZE = 10; -export const MAX_SIZE = 10000; - -export type MaybeRawData = SearchResponse['fields'] | undefined; // note: this is the type of the "fields" property in the ES response - -export const isRawDataValid = (rawData: MaybeRawData): rawData is Record<string, unknown[]> => - typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); - -export const getRawDataOrDefault = (rawData: MaybeRawData): Record<string, unknown[]> => - isRawDataValid(rawData) ? rawData : {}; - -export const sizeIsOutOfRange = (size?: number): boolean => - size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts index 09bae1639f1b1..45587b65f5f4c 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts @@ -10,12 +10,13 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { DynamicTool } from '@langchain/core/tools'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts_tool'; -import { MAX_SIZE } from './helpers'; import type { RetrievalQAChain } from 'langchain/chains'; import { mockAlertsFieldsApi } from '@kbn/elastic-assistant-plugin/server/__mocks__/alerts'; import type { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen'; import { loggerMock } from '@kbn/logging-mocks'; +const MAX_SIZE = 10000; + describe('OpenAndAcknowledgedAlertsTool', () => { const alertsIndexPattern = 'alerts-index'; const esClient = { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts index d6b0ad58d8adb..cab015183f4a2 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts @@ -7,13 +7,17 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { Replacements } from '@kbn/elastic-assistant-common'; -import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; +import { + getAnonymizedValue, + getOpenAndAcknowledgedAlertsQuery, + getRawDataOrDefault, + sizeIsOutOfRange, + transformRawData, +} from '@kbn/elastic-assistant-common'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; import { z } from '@kbn/zod'; import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; -import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; -import { getRawDataOrDefault, sizeIsOutOfRange } from './helpers'; import { APP_UI_ID } from '../../../../common'; export interface OpenAndAcknowledgedAlertsToolParams extends AssistantToolParams { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 0d369f3c620c4..ce79bd061548f 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -205,7 +205,6 @@ "@kbn/search-types", "@kbn/field-utils", "@kbn/core-saved-objects-api-server-mocks", - "@kbn/langchain", "@kbn/core-analytics-browser", "@kbn/core-i18n-browser", "@kbn/core-theme-browser", From 7c3887309cec54cc21e1abf8a2522afa49147712 Mon Sep 17 00:00:00 2001 From: Juan Pablo Djeredjian <jpdjeredjian@gmail.com> Date: Tue, 15 Oct 2024 22:51:25 -0300 Subject: [PATCH 29/31] [Security Solution] Extend upgrade perform endpoint logic (#191439) Fixes: https://github.com/elastic/kibana/issues/166376 (main ticket) Fixes: https://github.com/elastic/kibana/issues/186544 (handling of specific fields) Fixes: https://github.com/elastic/kibana/issues/180195 (replace PATCH with PUT logic on rule upgrade) ## Summary - Enhances the `/upgrade/_perform` endpoint to upgrade rules in a way that works with prebuilt rules customized by users and resolve conflicts between user customizations and updates from Elastic. - Handles special fields under the hood (see below) - Replaces the update prebuilt rule logic to work with PUT instead of PATCH. ### Rough implementation plan - For each `upgradeableRule`, we attempt to build the payload necessary to pass to `upgradePrebuiltRules()`, which is of type `PrebuiltRuleAsset`. So we retrieve the field names from `FIELDS_PAYLOAD_BY_RULE_TYPE` and loop through them. - If any of those `field`s are non-upgreadable, (i.e. its value needs to be handled under the hood) we do so in `determineFieldUpgradeStatus`. - Otherwise, we continue to build a `FieldUpgradeSpecifier` for each field, which will help us determine if that field needs to be set to the base, current, target version, OR if it needs to be calculated as a MERGED value, or it is passed in the request payload as a RESOLVED value. - Notice that we are iterating over "flat" (non-grouped) fields which are part of the `PrebuiltRuleAsset` schema. This means that mapping is necessary between these flat fields and the diffable (grouped) fields that are used in the API contract, part of `DiffableRule`. For example, if we try to determine the value for the `query` field, we will need to look up for its value in the `eql_query` field if the target rule is `eql` or in `esql_query` if the target rule is `esql`. All these mappings can be found in `diffable_rule_fields_mappings.ts`. - Once a `FieldUpgradeSpecifier` has been retrieved for each field of the payload we are building, retrieve its actual value: either fetching it from the base, current or target versions of the rule, from the three way diff calculation, or retrieving it from the request payload if it resolved. - Do this for all upgreadable rules, and the pass the payload array into `upgradePrebuiltRules()`. - **IMPORTANT:** The upgrade prebuilt rules logic has been changed from PATCH to PUT. That means that if the next version of a rule removes a field, and the user updates to that target version, those fields will be undefined in the resulting rule. **Additional example:** a installs a rule, and creates a `timeline_id` for it rule by modifying it. If neither the next version (target version) still does not have a `timeline_id` field for it, and the user updates to that target version fully (without resolving the conflict), that field will not exist anymore in the resulting rule. ## Acceptance criteria - [x] Extend the contract of the API endpoint according to the [POC](https://github.com/elastic/kibana/pull/144060): - [x] Add the ability to pick the `MERGED` version for rule upgrades. If the `MERGED` version is selected, the diffs are recalculated and the rule fields are updated to the result of the diff calculation. This is only possible if all field diffs return a `conflict` value of either `NO`. If any fields returns a value of `NON_SOLVABLE` or `SOLVABLE`, reject the request with an error specifying that there are conflicts, and that they must be resolved on a per-field basis. - [x] Calculate diffs inside this endpoint, when the value of `pick_version` is `MERGED`. - [x] Add the ability to specify rule field versions, to update specific fields to different `pick_versions`: `BASE' | 'CURRENT' | 'TARGET' | 'MERGED' | 'RESOLVED'` (See `FieldUpgradeRequest` in [PoC](https://github.com/elastic/kibana/pull/144060) for details) ## Handling of special fields Specific fields are handled under the hood based on https://github.com/elastic/kibana/issues/186544 See implementation in `x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/determine_field_upgrade_status.ts`, which imports fields to handle under the hood: - `DiffableFieldsToOmit` - `FieldsToUpdateToCurrentVersion` ## Edge cases - [x] If target version of rule has a **rule type change**, check that all `pick_version`, at all levels, match `TARGET`. Otherwise, create new error and add to ruleErrors array. - [x] if a rule has a specific `targetVersion.type` (for example, EQL) and the user includes in its `fields` object of the request payload any fields which do not match that rule type (in this case, for example, sending in `machine_learning_job_id` as part of `fields`), throw an error for that rule. - [x] Calculation of field diffs: what happens if some fields have a conflict value of `NON_SOLVABLE`: - [x] If the whole rule is being updated to `MERGED`, and **ANY** fields return with a `NON_SOLVABLE` conflict, reject the whole update for that rule: create new error and add to ruleErrors array. - [x] **EXCEPTION** for case above: the whole rule is being updated to `MERGED`, and one or more of the fields return with a `NON_SOLVABLE` conflict, BUT those same fields have a specific `pick_version` for them in the `fields` object which **ARE NOT** `MERGED`. No error should be reported in this case. - [x] The whole rule is being updated to any `pick_version` other than MERGED, but any specific field in the `fields` object is set to upgrade to `MERGED`, and the diff for that fields returns a `NON_SOLVABLE` conflict. In that case, create new error and add to ruleErrors array. ### TODO - [[Security Solution] Add InvestigationFields and AlertSuppression fields to the upgrade workflow [#190597]](https://github.com/elastic/kibana/issues/190597): InvestigationFields is already working, but AlertSuppression is still currently handled under the hood to update to current version. ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Maxim Palenov <maxim.palenov@elastic.co> --- .../model/diff/diffable_rule/diffable_rule.ts | 42 +- .../perform_rule_upgrade_route.ts | 51 +- .../get_prebuilt_rules_status_route.ts | 4 +- .../perform_rule_installation_route.ts | 4 +- ...rt_diffable_fields_match_rule_type.test.ts | 60 ++ .../assert_diffable_fields_match_rule_type.ts | 40 + .../assert_pick_version_is_target.test.ts | 131 +++ .../assert_pick_version_is_target.ts | 48 + .../create_field_upgrade_specifier.test.ts | 118 +++ .../create_field_upgrade_specifier.ts | 72 ++ .../create_props_to_rule_type_map.ts | 43 + .../create_upgradeable_rules_payload.ts | 145 +++ .../diffable_rule_fields_mappings.ts | 211 +++++ .../get_field_predefined_value.test.ts | 65 ++ .../get_field_predefined_value.ts | 73 ++ .../get_upgradeable_rules.test.ts | 191 ++++ .../get_upgradeable_rules.ts | 83 ++ .../get_value_for_field.ts | 94 ++ .../get_value_from_rule_version.ts | 94 ++ .../perform_rule_upgrade_route.ts | 122 +-- .../review_rule_installation_route.ts | 4 +- .../review_rule_upgrade_route.ts | 4 +- .../prebuilt_rule_assets_client.ts | 2 +- .../fetch_rule_versions_triad.ts | 2 +- .../rule_versions/rule_version_specifier.ts | 0 .../rule_assets/prebuilt_rule_asset.mock.ts | 200 +++- .../model/rule_assets/prebuilt_rule_asset.ts | 30 +- .../get_rule_groups.ts} | 34 +- .../mergers/apply_rule_patch.ts | 2 + .../methods/upgrade_prebuilt_rule.ts | 14 +- .../update_actions.ts | 14 + .../get_prebuilt_rules_status.ts | 16 +- .../trial_license_complete_tier/index.ts | 2 + ...e_perform_prebuilt_rules.all_rules_mode.ts | 490 ++++++++++ ...form_prebuilt_rules.specific_rules_mode.ts | 861 ++++++++++++++++++ .../upgrade_prebuilt_rules.ts | 25 +- ...prebuilt_rules_with_historical_versions.ts | 12 +- .../update_prebuilt_rules_package.ts | 15 +- .../export_rules.ts | 1 + .../get_custom_query_rule_params.ts | 1 + .../create_prebuilt_rule_saved_objects.ts | 29 +- .../utils/rules/prebuilt_rules/index.ts | 2 +- ...s.ts => perform_upgrade_prebuilt_rules.ts} | 19 +- 43 files changed, 3202 insertions(+), 268 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_props_to_rule_type_map.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_for_field.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_from_rule_version.ts rename x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/{model => logic}/rule_versions/rule_version_specifier.ts (100%) rename x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/{rule_versions/get_version_buckets.ts => rule_groups/get_rule_groups.ts} (75%) create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.all_rules_mode.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.specific_rules_mode.ts rename x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/{upgrade_prebuilt_rules.ts => perform_upgrade_prebuilt_rules.ts} (67%) diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts index d0a4aa12533e0..6e24b902995f4 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts @@ -174,6 +174,17 @@ export const DiffableNewTermsFields = z.object({ alert_suppression: AlertSuppression.optional(), }); +export const DiffableFieldsByTypeUnion = z.discriminatedUnion('type', [ + DiffableCustomQueryFields, + DiffableSavedQueryFields, + DiffableEqlFields, + DiffableEsqlFields, + DiffableThreatMatchFields, + DiffableThresholdFields, + DiffableMachineLearningFields, + DiffableNewTermsFields, +]); + /** * Represents a normalized rule object that is suitable for passing to the diff algorithm. * Every top-level field of a diffable rule can be compared separately on its own. @@ -200,18 +211,6 @@ export const DiffableNewTermsFields = z.object({ * NOTE: Every top-level field in a DiffableRule MUST BE LOGICALLY INDEPENDENT from other * top-level fields. */ - -export const DiffableFieldsByTypeUnion = z.discriminatedUnion('type', [ - DiffableCustomQueryFields, - DiffableSavedQueryFields, - DiffableEqlFields, - DiffableEsqlFields, - DiffableThreatMatchFields, - DiffableThresholdFields, - DiffableMachineLearningFields, - DiffableNewTermsFields, -]); - export type DiffableRule = z.infer<typeof DiffableRule>; export const DiffableRule = z.intersection(DiffableCommonFields, DiffableFieldsByTypeUnion); @@ -246,3 +245,22 @@ export const DiffableAllFields = DiffableCommonFields.merge( .merge(DiffableMachineLearningFields.omit({ type: true })) .merge(DiffableNewTermsFields.omit({ type: true })) .merge(z.object({ type: DiffableRuleTypes })); + +const getRuleTypeFields = (schema: z.ZodObject<z.ZodRawShape>): string[] => + Object.keys(schema.shape); + +const createDiffableFieldsPerRuleType = (specificFields: z.ZodObject<z.ZodRawShape>): string[] => [ + ...getRuleTypeFields(DiffableCommonFields), + ...getRuleTypeFields(specificFields), +]; + +export const DIFFABLE_RULE_TYPE_FIELDS_MAP = new Map<DiffableRuleTypes, string[]>([ + ['query', createDiffableFieldsPerRuleType(DiffableCustomQueryFields)], + ['saved_query', createDiffableFieldsPerRuleType(DiffableSavedQueryFields)], + ['eql', createDiffableFieldsPerRuleType(DiffableEqlFields)], + ['esql', createDiffableFieldsPerRuleType(DiffableEsqlFields)], + ['threat_match', createDiffableFieldsPerRuleType(DiffableThreatMatchFields)], + ['threshold', createDiffableFieldsPerRuleType(DiffableThresholdFields)], + ['machine_learning', createDiffableFieldsPerRuleType(DiffableMachineLearningFields)], + ['new_terms', createDiffableFieldsPerRuleType(DiffableNewTermsFields)], +]); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts index c7d3227ef03f3..784f75d09bd7a 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/perform_rule_upgrade/perform_rule_upgrade_route.ts @@ -11,11 +11,52 @@ import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; import { AggregatedPrebuiltRuleError, DiffableAllFields } from '../model'; import { RuleSignatureId, RuleVersion } from '../../model'; +export type Mode = z.infer<typeof Mode>; +export const Mode = z.enum(['ALL_RULES', 'SPECIFIC_RULES']); +export type ModeEnum = typeof Mode.enum; +export const ModeEnum = Mode.enum; + export type PickVersionValues = z.infer<typeof PickVersionValues>; export const PickVersionValues = z.enum(['BASE', 'CURRENT', 'TARGET', 'MERGED']); export type PickVersionValuesEnum = typeof PickVersionValues.enum; export const PickVersionValuesEnum = PickVersionValues.enum; +// Specific handling of special fields according to: +// https://github.com/elastic/kibana/issues/186544 +export const FIELDS_TO_UPGRADE_TO_CURRENT_VERSION = [ + 'enabled', + 'exceptions_list', + 'alert_suppression', + 'actions', + 'throttle', + 'response_actions', + 'meta', + 'output_index', + 'namespace', + 'alias_purpose', + 'alias_target_id', + 'outcome', + 'concurrent_searches', + 'items_per_search', +] as const; + +export const NON_UPGRADEABLE_DIFFABLE_FIELDS = [ + 'type', + 'rule_id', + 'version', + 'author', + 'license', +] as const; + +type NON_UPGRADEABLE_DIFFABLE_FIELDS_TO_OMIT_TYPE = { + readonly [key in (typeof NON_UPGRADEABLE_DIFFABLE_FIELDS)[number]]: true; +}; + +// This transformation is needed to have Zod's `omit` accept the rule fields that need to be omitted +export const DiffableFieldsToOmit = NON_UPGRADEABLE_DIFFABLE_FIELDS.reduce((acc, field) => { + return { ...acc, [field]: true }; +}, {} as NON_UPGRADEABLE_DIFFABLE_FIELDS_TO_OMIT_TYPE); + /** * Fields upgradable by the /upgrade/_perform endpoint. * Specific fields are omitted because they are not upgradeable, and @@ -23,18 +64,12 @@ export const PickVersionValuesEnum = PickVersionValues.enum; * See: https://github.com/elastic/kibana/issues/186544 */ export type DiffableUpgradableFields = z.infer<typeof DiffableUpgradableFields>; -export const DiffableUpgradableFields = DiffableAllFields.omit({ - type: true, - rule_id: true, - version: true, - author: true, - license: true, -}); +export const DiffableUpgradableFields = DiffableAllFields.omit(DiffableFieldsToOmit); export type FieldUpgradeSpecifier<T> = z.infer< ReturnType<typeof fieldUpgradeSpecifier<z.ZodType<T>>> >; -const fieldUpgradeSpecifier = <T extends z.ZodTypeAny>(fieldSchema: T) => +export const fieldUpgradeSpecifier = <T extends z.ZodTypeAny>(fieldSchema: T) => z.discriminatedUnion('pick_version', [ z .object({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts index a5596ca4c8498..86809a3a79a93 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/get_prebuilt_rules_status/get_prebuilt_rules_status_route.ts @@ -13,7 +13,7 @@ import { buildSiemResponse } from '../../../routes/utils'; import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client'; import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; -import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; +import { getRuleGroups } from '../../model/rule_groups/get_rule_groups'; export const getPrebuiltRulesStatusRoute = (router: SecuritySolutionPluginRouter) => { router.versioned @@ -44,7 +44,7 @@ export const getPrebuiltRulesStatusRoute = (router: SecuritySolutionPluginRouter ruleObjectsClient, }); const { currentRules, installableRules, upgradeableRules, totalAvailableRules } = - getVersionBuckets(ruleVersionsMap); + getRuleGroups(ruleVersionsMap); const body: GetPrebuiltRulesStatusResponseBody = { stats: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts index 8ffec60a26c11..1a29568ca496b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_route.ts @@ -25,9 +25,9 @@ import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt import { createPrebuiltRules } from '../../logic/rule_objects/create_prebuilt_rules'; import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; -import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; import { performTimelinesInstallation } from '../../logic/perform_timelines_installation'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; +import { getRuleGroups } from '../../model/rule_groups/get_rule_groups'; export const performRuleInstallationRoute = (router: SecuritySolutionPluginRouter) => { router.versioned @@ -80,7 +80,7 @@ export const performRuleInstallationRoute = (router: SecuritySolutionPluginRoute ruleObjectsClient, versionSpecifiers: mode === 'ALL_RULES' ? undefined : request.body.rules, }); - const { currentRules, installableRules } = getVersionBuckets(ruleVersionsMap); + const { currentRules, installableRules } = getRuleGroups(ruleVersionsMap); // Perform all the checks we can before we start the upgrade process if (mode === 'SPECIFIC_RULES') { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.test.ts new file mode 100644 index 0000000000000..a7ff15a82a3db --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertDiffableFieldsMatchRuleType } from './assert_diffable_fields_match_rule_type'; +import { DIFFABLE_RULE_TYPE_FIELDS_MAP } from '../../../../../../common/api/detection_engine'; + +describe('assertDiffableFieldsMatchRuleType', () => { + describe('valid scenarios -', () => { + it('should validate all fields in DIFFABLE_RULE_TYPE_FIELDS_MAP', () => { + DIFFABLE_RULE_TYPE_FIELDS_MAP.forEach((fields, ruleType) => { + expect(() => { + assertDiffableFieldsMatchRuleType(fields, ruleType); + }).not.toThrow(); + }); + }); + + it('should not throw an error for valid upgradeable fields', () => { + expect(() => { + assertDiffableFieldsMatchRuleType(['name', 'description', 'severity'], 'query'); + }).not.toThrow(); + }); + + it('should handle valid rule type correctly', () => { + expect(() => { + assertDiffableFieldsMatchRuleType(['eql_query'], 'eql'); + }).not.toThrow(); + }); + + it('should handle empty upgradeable fields array', () => { + expect(() => { + assertDiffableFieldsMatchRuleType([], 'query'); + }).not.toThrow(); + }); + }); + + describe('invalid scenarios -', () => { + it('should throw an error for invalid upgradeable fields', () => { + expect(() => { + assertDiffableFieldsMatchRuleType(['invalid_field'], 'query'); + }).toThrow("invalid_field is not a valid upgradeable field for type 'query'"); + }); + + it('should throw for incompatible rule types', () => { + expect(() => { + assertDiffableFieldsMatchRuleType(['eql_query'], 'query'); + }).toThrow("eql_query is not a valid upgradeable field for type 'query'"); + }); + + it('should throw an error for an unknown rule type', () => { + expect(() => { + // @ts-expect-error - unknown rule + assertDiffableFieldsMatchRuleType(['name'], 'unknown_type'); + }).toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.ts new file mode 100644 index 0000000000000..14ac905ca885d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_diffable_fields_match_rule_type.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiffableRuleTypes } from '../../../../../../common/api/detection_engine'; +import { DIFFABLE_RULE_TYPE_FIELDS_MAP } from '../../../../../../common/api/detection_engine'; + +/** + * Validates that the upgradeable (diffable) fields match the target rule type's diffable fields. + * + * This function is used in the rule upgrade process to ensure that the fields + * specified for upgrade in the request body are valid for the target rule type. + * It checks each upgradeable field provided in body.rule[].fields against the + * set of diffable fields for the target rule type. + * + * @param {string[]} diffableFields - An array of field names to be upgraded. + * @param {string} ruleType - A rule type (e.g., 'query', 'eql', 'machine_learning'). + * @throws {Error} If an upgradeable field is not valid for the target rule type. + * + * @examples + * assertDiffableFieldsMatchRuleType(['kql_query', 'severity'], 'query'); + * assertDiffableFieldsMatchRuleType(['esql_query', 'description'], 'esql'); + * assertDiffableFieldsMatchRuleType(['machine_learning_job_id'], 'eql'); // throws error + * + * @see {@link DIFFABLE_RULE_TYPE_FIELDS_MAP} in diffable_rule.ts for the mapping of rule types to their diffable fields. + */ +export const assertDiffableFieldsMatchRuleType = ( + diffableFields: string[], + ruleType: DiffableRuleTypes +) => { + const diffableFieldsForType = new Set(DIFFABLE_RULE_TYPE_FIELDS_MAP.get(ruleType)); + for (const diffableField of diffableFields) { + if (!diffableFieldsForType.has(diffableField)) { + throw new Error(`${diffableField} is not a valid upgradeable field for type '${ruleType}'`); + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.test.ts new file mode 100644 index 0000000000000..d4cd1ae010067 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertPickVersionIsTarget } from './assert_pick_version_is_target'; +import type { + PerformRuleUpgradeRequestBody, + PickVersionValues, +} from '../../../../../../common/api/detection_engine'; + +describe('assertPickVersionIsTarget', () => { + const ruleId = 'test-rule-id'; + const createExpectedError = (id: string) => + `Rule update for rule ${id} has a rule type change. All 'pick_version' values for rule must match 'TARGET'`; + + describe('valid cases - ', () => { + it('should not throw when pick_version is TARGET for ALL_RULES mode', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).not.toThrow(); + }); + + it('should not throw when pick_version is TARGET for SPECIFIC_RULES mode', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'SPECIFIC_RULES', + rules: [ + { + rule_id: ruleId, + revision: 1, + version: 1, + pick_version: 'TARGET', + }, + ], + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).not.toThrow(); + }); + + it('should not throw when all pick_version values are TARGET', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'SPECIFIC_RULES', + pick_version: 'TARGET', + rules: [ + { + rule_id: ruleId, + revision: 1, + version: 1, + pick_version: 'TARGET', + fields: { + name: { pick_version: 'TARGET' }, + description: { pick_version: 'TARGET' }, + }, + }, + ], + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).not.toThrow(); + }); + }); + + describe('invalid cases - ', () => { + it('should throw when pick_version is not TARGET for ALL_RULES mode', () => { + const pickVersions: PickVersionValues[] = ['BASE', 'CURRENT', 'MERGED']; + + pickVersions.forEach((pickVersion) => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'ALL_RULES', + pick_version: pickVersion, + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).toThrowError( + createExpectedError(ruleId) + ); + }); + }); + + it('should throw when pick_version is not TARGET for SPECIFIC_RULES mode', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'SPECIFIC_RULES', + rules: [ + { + rule_id: ruleId, + revision: 1, + version: 1, + pick_version: 'BASE', + }, + ], + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).toThrowError( + createExpectedError(ruleId) + ); + }); + + it('should throw when any field-specific pick_version is not TARGET', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'SPECIFIC_RULES', + rules: [ + { + rule_id: ruleId, + revision: 1, + version: 1, + pick_version: 'TARGET', + fields: { + name: { pick_version: 'BASE' }, + }, + }, + ], + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).toThrowError( + createExpectedError(ruleId) + ); + }); + + it('should throw when pick_version is missing (defaults to MERGED)', () => { + const requestBody: PerformRuleUpgradeRequestBody = { + mode: 'SPECIFIC_RULES', + rules: [{ rule_id: ruleId, revision: 1, version: 1 }], + }; + + expect(() => assertPickVersionIsTarget({ requestBody, ruleId })).toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.ts new file mode 100644 index 0000000000000..63e67512be249 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/assert_pick_version_is_target.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + PerformRuleUpgradeRequestBody, + PickVersionValues, +} from '../../../../../../common/api/detection_engine'; + +interface AssertRuleTypeMatchProps { + requestBody: PerformRuleUpgradeRequestBody; + ruleId: string; +} + +/* + * Assert that, in the case where the rule is undergoing a rule type change, + * the pick_version value is set to 'TARGET' at all levels (global, rule-specific and field-specific) + */ +export const assertPickVersionIsTarget = ({ requestBody, ruleId }: AssertRuleTypeMatchProps) => { + const pickVersions: Array<PickVersionValues | 'RESOLVED'> = []; + + if (requestBody.mode === 'SPECIFIC_RULES') { + const rulePayload = requestBody.rules.find((rule) => rule.rule_id === ruleId); + + // Rule-level pick_version overrides global pick_version. Pick rule-level pick_version if it + // exists, otherwise use global pick_version. If none exist, we default to 'MERGED'. + pickVersions.push(rulePayload?.pick_version ?? requestBody.pick_version ?? 'MERGED'); + + if (rulePayload?.fields) { + const fieldPickValues = Object.values(rulePayload?.fields).map((field) => field.pick_version); + pickVersions.push(...fieldPickValues); + } + } else { + // mode: ALL_RULES + pickVersions.push(requestBody.pick_version ?? 'MERGED'); + } + + const allPickVersionsAreTarget = pickVersions.every((version) => version === 'TARGET'); + + // If pick_version is provided at any levels, they must all be set to 'TARGET' + if (!allPickVersionsAreTarget) { + throw new Error( + `Rule update for rule ${ruleId} has a rule type change. All 'pick_version' values for rule must match 'TARGET'` + ); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.test.ts new file mode 100644 index 0000000000000..ac5db9ef1e7f2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createFieldUpgradeSpecifier } from './create_field_upgrade_specifier'; +import { + PickVersionValuesEnum, + type DiffableRuleTypes, +} from '../../../../../../common/api/detection_engine'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; + +describe('createFieldUpgradeSpecifier', () => { + const defaultArgs = { + fieldName: 'name' as keyof PrebuiltRuleAsset, + globalPickVersion: PickVersionValuesEnum.MERGED, + ruleId: 'rule-1', + targetRuleType: 'query' as DiffableRuleTypes, + }; + + it('should return rule-specific pick version when no specific fields are defined', () => { + const result = createFieldUpgradeSpecifier({ + ...defaultArgs, + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + pick_version: PickVersionValuesEnum.BASE, + revision: 1, + version: 1, + }, + }); + expect(result).toEqual({ pick_version: PickVersionValuesEnum.BASE }); + }); + + it('should return field-specific pick version when defined', () => { + const result = createFieldUpgradeSpecifier({ + ...defaultArgs, + fieldName: 'description', + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + pick_version: PickVersionValuesEnum.TARGET, + revision: 1, + version: 1, + fields: { description: { pick_version: PickVersionValuesEnum.CURRENT } }, + }, + }); + expect(result).toEqual({ + pick_version: PickVersionValuesEnum.CURRENT, + }); + }); + + it('should return resolved value for specifc fields with RESOLVED pick versions', () => { + const result = createFieldUpgradeSpecifier({ + ...defaultArgs, + fieldName: 'description', + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + revision: 1, + version: 1, + fields: { + description: { pick_version: 'RESOLVED', resolved_value: 'New description' }, + }, + }, + }); + expect(result).toEqual({ + pick_version: 'RESOLVED', + resolved_value: 'New description', + }); + }); + + it('should handle fields that require mapping', () => { + const result = createFieldUpgradeSpecifier({ + ...defaultArgs, + fieldName: 'index' as keyof PrebuiltRuleAsset, + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + revision: 1, + version: 1, + fields: { data_source: { pick_version: PickVersionValuesEnum.CURRENT } }, + }, + }); + expect(result).toEqual({ pick_version: PickVersionValuesEnum.CURRENT }); + }); + + it('should fall back to rule-level pick version when field is not specified', () => { + const result = createFieldUpgradeSpecifier({ + ...defaultArgs, + fieldName: 'description', + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + pick_version: PickVersionValuesEnum.TARGET, + revision: 1, + version: 1, + fields: { name: { pick_version: PickVersionValuesEnum.CURRENT } }, + }, + }); + expect(result).toEqual({ + pick_version: PickVersionValuesEnum.TARGET, + }); + }); + + it('should throw error if field is not a valid upgradeable field', () => { + // machine_learning_job_id field does not match 'eql' target rule type + expect(() => + createFieldUpgradeSpecifier({ + ...defaultArgs, + targetRuleType: 'eql', + ruleUpgradeSpecifier: { + rule_id: 'rule-1', + revision: 1, + version: 1, + fields: { machine_learning_job_id: { pick_version: PickVersionValuesEnum.CURRENT } }, + }, + }) + ).toThrowError(`machine_learning_job_id is not a valid upgradeable field for type 'eql'`); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.ts new file mode 100644 index 0000000000000..7526394c5a75f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_field_upgrade_specifier.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { assertDiffableFieldsMatchRuleType } from './assert_diffable_fields_match_rule_type'; +import { + type UpgradeSpecificRulesRequest, + type RuleFieldsToUpgrade, + type DiffableRuleTypes, + type FieldUpgradeSpecifier, + type PickVersionValues, +} from '../../../../../../common/api/detection_engine'; +import { type PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import { mapRuleFieldToDiffableRuleField } from './diffable_rule_fields_mappings'; + +interface CreateFieldUpgradeSpecifierArgs { + fieldName: keyof PrebuiltRuleAsset; + ruleUpgradeSpecifier: UpgradeSpecificRulesRequest['rules'][number]; + targetRuleType: DiffableRuleTypes; + globalPickVersion: PickVersionValues; +} + +/** + * Creates a field upgrade specifier for a given field in PrebuiltRuleAsset. + * + * This function determines how a specific field should be upgraded based on the + * upgrade request body and the pick_version at global, rule and field-levels, + * when the mode is SPECIFIC_RULES. + */ +export const createFieldUpgradeSpecifier = ({ + fieldName, + ruleUpgradeSpecifier, + targetRuleType, + globalPickVersion, +}: CreateFieldUpgradeSpecifierArgs): FieldUpgradeSpecifier<unknown> => { + if (!ruleUpgradeSpecifier.fields || Object.keys(ruleUpgradeSpecifier.fields).length === 0) { + return { + pick_version: ruleUpgradeSpecifier.pick_version ?? globalPickVersion, + }; + } + + assertDiffableFieldsMatchRuleType(Object.keys(ruleUpgradeSpecifier.fields), targetRuleType); + + const fieldsToUpgradePayload = ruleUpgradeSpecifier.fields as Record< + string, + RuleFieldsToUpgrade[keyof RuleFieldsToUpgrade] + >; + + const fieldGroup = mapRuleFieldToDiffableRuleField({ + ruleType: targetRuleType, + fieldName, + }); + + const fieldUpgradeSpecifier = fieldsToUpgradePayload[fieldGroup]; + + if (fieldUpgradeSpecifier?.pick_version === 'RESOLVED') { + return { + pick_version: 'RESOLVED', + resolved_value: fieldUpgradeSpecifier.resolved_value, + }; + } + + return { + pick_version: + // If there's no matching specific field upgrade specifier in the payload, + // we fallback to a rule level pick_version. Since this is also optional, + // we default to the global pick_version. + fieldUpgradeSpecifier?.pick_version ?? ruleUpgradeSpecifier.pick_version ?? globalPickVersion, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_props_to_rule_type_map.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_props_to_rule_type_map.ts new file mode 100644 index 0000000000000..d0b798fabaeb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_props_to_rule_type_map.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SharedCreateProps, + TypeSpecificCreatePropsInternal, +} from '../../../../../../common/api/detection_engine'; +import { type PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; + +function createRuleTypeToCreateRulePropsMap() { + // SharedCreateProps is an extension of BaseCreateProps, but includes rule_id + const baseFields = Object.keys(SharedCreateProps.shape); + + return new Map( + TypeSpecificCreatePropsInternal.options.map((option) => { + const typeName = option.shape.type.value; + const typeSpecificFieldsForType = Object.keys(option.shape); + + return [typeName, [...baseFields, ...typeSpecificFieldsForType] as [keyof PrebuiltRuleAsset]]; + }) + ); +} + +/** + * Map of the CreateProps field names, by rule type. + * + * Helps creating the payload to be passed to the `upgradePrebuiltRules()` method during the + * Upgrade workflow (`/upgrade/_perform` endpoint) + * + * Creating this Map dynamically, based on BaseCreateProps and TypeSpecificFields, ensures that we don't need to: + * - manually add rule types to this Map if they are created + * - manually add or remove any fields if they are added or removed to a specific rule type + * - manually add or remove any fields if we decide that they should not be part of the upgradable fields. + * + * Notice that this Map includes, for each rule type, all fields that are part of the BaseCreateProps and all fields that + * are part of the TypeSpecificFields, including those that are not part of RuleUpgradeSpecifierFields schema, where + * the user of the /upgrade/_perform endpoint can specify which fields to upgrade during the upgrade workflow. + */ +export const FIELD_NAMES_BY_RULE_TYPE_MAP = createRuleTypeToCreateRulePropsMap(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts new file mode 100644 index 0000000000000..97e587646e524 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/create_upgradeable_rules_payload.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { pickBy } from 'lodash'; +import type { PromisePoolError } from '../../../../../utils/promise_pool'; +import { + PickVersionValuesEnum, + type PerformRuleUpgradeRequestBody, + type PickVersionValues, + type AllFieldsDiff, + MissingVersion, +} from '../../../../../../common/api/detection_engine'; +import { convertRuleToDiffable } from '../../../../../../common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import { assertPickVersionIsTarget } from './assert_pick_version_is_target'; +import { FIELD_NAMES_BY_RULE_TYPE_MAP } from './create_props_to_rule_type_map'; +import { calculateRuleFieldsDiff } from '../../logic/diff/calculation/calculate_rule_fields_diff'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; +import { getValueForField } from './get_value_for_field'; + +interface CreateModifiedPrebuiltRuleAssetsProps { + upgradeableRules: RuleTriad[]; + requestBody: PerformRuleUpgradeRequestBody; +} + +interface ProcessedRules { + modifiedPrebuiltRuleAssets: PrebuiltRuleAsset[]; + processingErrors: Array<PromisePoolError<{ rule_id: string }>>; +} + +export const createModifiedPrebuiltRuleAssets = ({ + upgradeableRules, + requestBody, +}: CreateModifiedPrebuiltRuleAssetsProps) => { + const { pick_version: globalPickVersion = PickVersionValuesEnum.MERGED, mode } = requestBody; + + const { modifiedPrebuiltRuleAssets, processingErrors } = upgradeableRules.reduce<ProcessedRules>( + (processedRules, upgradeableRule) => { + const targetRuleType = upgradeableRule.target.type; + const ruleId = upgradeableRule.target.rule_id; + const fieldNames = FIELD_NAMES_BY_RULE_TYPE_MAP.get(targetRuleType); + + try { + if (fieldNames === undefined) { + throw new Error(`Unexpected rule type: ${targetRuleType}`); + } + + const { current, target } = upgradeableRule; + if (current.type !== target.type) { + assertPickVersionIsTarget({ ruleId, requestBody }); + } + + const calculatedRuleDiff = calculateRuleFieldsDiff({ + base_version: upgradeableRule.base + ? convertRuleToDiffable(convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.base)) + : MissingVersion, + current_version: convertRuleToDiffable(upgradeableRule.current), + target_version: convertRuleToDiffable( + convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.target) + ), + }) as AllFieldsDiff; + + if (mode === 'ALL_RULES' && globalPickVersion === 'MERGED') { + const fieldsWithConflicts = Object.keys(getFieldsDiffConflicts(calculatedRuleDiff)); + if (fieldsWithConflicts.length > 0) { + // If the mode is ALL_RULES, no fields can be overriden to any other pick_version + // than "MERGED", so throw an error for the fields that have conflicts. + throw new Error( + `Merge conflicts found in rule '${ruleId}' for fields: ${fieldsWithConflicts.join( + ', ' + )}. Please resolve the conflict manually or choose another value for 'pick_version'` + ); + } + } + + const modifiedPrebuiltRuleAsset = createModifiedPrebuiltRuleAsset({ + upgradeableRule, + fieldNames, + requestBody, + globalPickVersion, + calculatedRuleDiff, + }); + + processedRules.modifiedPrebuiltRuleAssets.push(modifiedPrebuiltRuleAsset); + + return processedRules; + } catch (err) { + processedRules.processingErrors.push({ + error: err, + item: { rule_id: ruleId }, + }); + + return processedRules; + } + }, + { + modifiedPrebuiltRuleAssets: [], + processingErrors: [], + } + ); + + return { + modifiedPrebuiltRuleAssets, + processingErrors, + }; +}; + +interface CreateModifiedPrebuiltRuleAssetParams { + upgradeableRule: RuleTriad; + fieldNames: Array<keyof PrebuiltRuleAsset>; + globalPickVersion: PickVersionValues; + requestBody: PerformRuleUpgradeRequestBody; + calculatedRuleDiff: AllFieldsDiff; +} + +function createModifiedPrebuiltRuleAsset({ + upgradeableRule, + fieldNames, + globalPickVersion, + requestBody, + calculatedRuleDiff, +}: CreateModifiedPrebuiltRuleAssetParams): PrebuiltRuleAsset { + const modifiedPrebuiltRuleAsset = {} as Record<string, unknown>; + + for (const fieldName of fieldNames) { + modifiedPrebuiltRuleAsset[fieldName] = getValueForField({ + fieldName, + upgradeableRule, + globalPickVersion, + requestBody, + ruleFieldsDiff: calculatedRuleDiff, + }); + } + + return modifiedPrebuiltRuleAsset as PrebuiltRuleAsset; +} + +const getFieldsDiffConflicts = (ruleFieldsDiff: Partial<AllFieldsDiff>) => + pickBy(ruleFieldsDiff, (diff) => { + return diff.conflict !== 'NONE'; + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts new file mode 100644 index 0000000000000..d56747f9db264 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { get } from 'lodash'; +import type { + RuleSchedule, + InlineKqlQuery, + ThreeWayDiff, + DiffableRuleTypes, +} from '../../../../../../common/api/detection_engine'; +import { type AllFieldsDiff } from '../../../../../../common/api/detection_engine'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; + +/** + * Retrieves and transforms the value for a specific field from a DiffableRule group. + * + * Maps PrebuiltRuleAsset schema fields to their corresponding DiffableRule group values. It also + * applies necessary transformations to ensure the returned value matches the expected format + * for the PrebuiltRuleAsset schema. + * + * @param {keyof PrebuiltRuleAsset} field - The field name in the PrebuiltRuleAsset schema. + * @param {ThreeWayDiff<unknown>['merged_version']} diffableField - The corresponding field value from the DiffableRule. + * + * @example + * // For an 'index' field + * mapDiffableRuleFieldValueToRuleSchema('index', { index_patterns: ['logs-*'] }) + * // Returns: ['logs-*'] + * + * @example + * // For a 'from' field in a rule schedule + * mapDiffableRuleFieldValueToRuleSchema('from', { interval: '5d', lookback: '30d' }) + * // Returns: 'now-30d' + * + */ +export const mapDiffableRuleFieldValueToRuleSchemaFormat = ( + fieldName: keyof PrebuiltRuleAsset, + diffableField: ThreeWayDiff<unknown>['merged_version'] +) => { + const diffableRuleSubfieldName = mapRuleFieldToDiffableRuleSubfield(fieldName); + + const transformedValue = transformDiffableFieldValues(fieldName, diffableField); + if (transformedValue.type === 'TRANSFORMED_FIELD') { + return transformedValue.value; + } + + // From the ThreeWayDiff, get the specific field that maps to the diffable rule field + // Otherwise, the diffableField itself already matches the rule field, so retrieve that value. + const mappedField = get(diffableField, diffableRuleSubfieldName, diffableField); + + return mappedField; +}; + +interface MapRuleFieldToDiffableRuleFieldParams { + ruleType: DiffableRuleTypes; + fieldName: string; +} +/** + * Maps a PrebuiltRuleAsset schema field name to its corresponding DiffableRule group. + * + * Determines which group in the DiffableRule schema a given field belongs to. Handles special + * cases for query-related fields based on the rule type. + * + * @param {string} fieldName - The field name from the PrebuiltRuleAsset schema. + * @param {string} ruleType - The type of the rule being processed. + * + * @example + * mapRuleFieldToDiffableRuleField('index', 'query') + * // Returns: 'data_source' + * + * @example + * mapRuleFieldToDiffableRuleField('query', 'eql') + * // Returns: 'eql_query' + * + */ +export function mapRuleFieldToDiffableRuleField({ + ruleType, + fieldName, +}: MapRuleFieldToDiffableRuleFieldParams): keyof AllFieldsDiff { + const diffableRuleFieldMap: Record<string, keyof AllFieldsDiff> = { + building_block_type: 'building_block', + saved_id: 'kql_query', + threat_query: 'threat_query', + threat_language: 'threat_query', + threat_filters: 'threat_query', + index: 'data_source', + data_view_id: 'data_source', + rule_name_override: 'rule_name_override', + interval: 'rule_schedule', + from: 'rule_schedule', + to: 'rule_schedule', + timeline_id: 'timeline_template', + timeline_title: 'timeline_template', + timestamp_override: 'timestamp_override', + timestamp_override_fallback_disabled: 'timestamp_override', + }; + + // Handle query, filters and language fields based on rule type + if (fieldName === 'query' || fieldName === 'language' || fieldName === 'filters') { + switch (ruleType) { + case 'query': + case 'saved_query': + return 'kql_query' as const; + case 'eql': + return 'eql_query'; + case 'esql': + return 'esql_query'; + default: + return 'kql_query'; + } + } + + return diffableRuleFieldMap[fieldName] || fieldName; +} + +/** + * Maps a PrebuiltRuleAsset schema field name to its corresponding property + * name within a DiffableRule group. + * + * @param {string} fieldName - The field name from the PrebuiltRuleAsset schema. + * @returns {string} The corresponding property name in the DiffableRule group. + * + * @example + * mapRuleFieldToDiffableRuleSubfield('index') + * // Returns: 'index_patterns' + * + * @example + * mapRuleFieldToDiffableRuleSubfield('from') + * // Returns: 'lookback' + * + */ +export function mapRuleFieldToDiffableRuleSubfield(fieldName: string): string { + const fieldMapping: Record<string, string> = { + index: 'index_patterns', + data_view_id: 'data_view_id', + saved_id: 'saved_query_id', + building_block_type: 'type', + rule_name_override: 'field_name', + timestamp_override: 'field_name', + timestamp_override_fallback_disabled: 'fallback_disabled', + timeline_id: 'timeline_id', + timeline_title: 'timeline_title', + interval: 'interval', + from: 'lookback', + to: 'lookback', + }; + + return fieldMapping[fieldName] || fieldName; +} + +type TransformValuesReturnType = + | { + type: 'TRANSFORMED_FIELD'; + value: unknown; + } + | { type: 'NON_TRANSFORMED_FIELD' }; + +/** + * Transforms specific field values from the DiffableRule format to the PrebuiltRuleAsset/RuleResponse format. + * + * This function is used in the rule upgrade process to ensure that certain fields + * are correctly formatted when creating the updated rules payload. It handles + * special cases where the format differs between the DiffableRule and the + * PrebuiltRuleAsset/RuleResponse schemas. + * + * @param {string} fieldName - The name of the field being processed. + * @param {RuleSchedule | InlineKqlQuery | unknown} diffableFieldValue - The value of the field in DiffableRule format. + * + * @returns {TransformValuesReturnType} An object indicating whether the field was transformed + * and its new value if applicable. + * - If transformed: { type: 'TRANSFORMED_FIELD', value: transformedValue } + * - If not transformed: { type: 'NON_TRANSFORMED_FIELD' } + * + * @example + * // Transforms 'from' field + * transformDiffableFieldValues('from', { lookback: '30d' }) + * // Returns: { type: 'TRANSFORMED_FIELD', value: 'now-30d' } + * + * @example + * // Transforms 'saved_id' field for inline queries + * transformDiffableFieldValues('saved_id', { type: 'inline_query', ... }) + * // Returns: { type: 'TRANSFORMED_FIELD', value: undefined } + * + */ +export const transformDiffableFieldValues = ( + fieldName: string, + diffableFieldValue: RuleSchedule | InlineKqlQuery | unknown +): TransformValuesReturnType => { + if (fieldName === 'from' && isRuleSchedule(diffableFieldValue)) { + return { type: 'TRANSFORMED_FIELD', value: `now-${diffableFieldValue.lookback}` }; + } else if (fieldName === 'to') { + return { type: 'TRANSFORMED_FIELD', value: `now` }; + } else if (fieldName === 'saved_id' && isInlineQuery(diffableFieldValue)) { + // saved_id should be set only for rules with SavedKqlQuery, undefined otherwise + return { type: 'TRANSFORMED_FIELD', value: undefined }; + } + + return { type: 'NON_TRANSFORMED_FIELD' }; +}; + +function isRuleSchedule(value: unknown): value is RuleSchedule { + return typeof value === 'object' && value !== null && 'lookback' in value; +} + +function isInlineQuery(value: unknown): value is InlineKqlQuery { + return ( + typeof value === 'object' && value !== null && 'type' in value && value.type === 'inline_query' + ); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.test.ts new file mode 100644 index 0000000000000..9a1ca051c54fa --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFieldPredefinedValue } from './get_field_predefined_value'; +import { + NON_UPGRADEABLE_DIFFABLE_FIELDS, + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION, +} from '../../../../../../common/api/detection_engine'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; + +describe('getFieldPredefinedValue', () => { + const mockUpgradeableRule = { + current: { + rule_id: 'current_rule_id', + type: 'query', + enabled: true, + name: 'Current Rule', + description: 'Current description', + version: 1, + author: ['Current Author'], + license: 'Current License', + }, + target: { + rule_id: 'target_rule_id', + type: 'query', + enabled: false, + name: 'Target Rule', + description: 'Target description', + version: 2, + author: ['Target Author'], + license: 'Target License', + }, + } as RuleTriad; + + it('should return PREDEFINED_VALUE with target value for fields in NON_UPGRADEABLE_DIFFABLE_FIELDS', () => { + NON_UPGRADEABLE_DIFFABLE_FIELDS.forEach((field) => { + const result = getFieldPredefinedValue(field as keyof PrebuiltRuleAsset, mockUpgradeableRule); + expect(result).toEqual({ + type: 'PREDEFINED_VALUE', + value: mockUpgradeableRule.target[field as keyof PrebuiltRuleAsset], + }); + }); + }); + + it('should return PREDEFINED_VALUE with current value for fields in FIELDS_TO_UPGRADE_TO_CURRENT_VERSION', () => { + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + const result = getFieldPredefinedValue(field as keyof PrebuiltRuleAsset, mockUpgradeableRule); + expect(result).toEqual({ + type: 'PREDEFINED_VALUE', + value: mockUpgradeableRule.current[field as keyof PrebuiltRuleAsset], + }); + }); + }); + + it('should return CUSTOMIZABLE_VALUE for fields not in NON_UPGRADEABLE_DIFFABLE_FIELDS or FIELDS_TO_UPGRADE_TO_CURRENT_VERSION', () => { + const upgradeableField = 'description'; + const result = getFieldPredefinedValue(upgradeableField, mockUpgradeableRule); + expect(result).toEqual({ type: 'CUSTOMIZABLE_VALUE' }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.ts new file mode 100644 index 0000000000000..777711e56470c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_field_predefined_value.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION, + NON_UPGRADEABLE_DIFFABLE_FIELDS, +} from '../../../../../../common/api/detection_engine'; +import { type PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; + +type GetFieldPredefinedValueReturnType = + | { + type: 'PREDEFINED_VALUE'; + value: unknown; + } + | { type: 'CUSTOMIZABLE_VALUE' }; + +/** + * Determines whether a field can be upgraded via API (i.e. whether it should take + * a predefined value or is customizable), and returns the value if it is predefined. + * + * This function checks whether a field can be upgraded via API contract and how it should + * be handled during the rule upgrade process. It uses the `NON_UPGRADEABLE_DIFFABLE_FIELDS` and + * `FIELDS_TO_UPGRADE_TO_CURRENT_VERSION` constants to make this determination. + * + * `NON_UPGRADEABLE_DIFFABLE_FIELDS` includes fields that are not upgradeable: 'type', 'rule_id', + * 'version', 'author', and 'license', and are always upgraded to the target version. + * + * `FIELDS_TO_UPGRADE_TO_CURRENT_VERSION` includes fields that should be updated to their + * current version, such as 'enabled', 'alert_suppression', 'actions', 'throttle', + * 'response_actions', 'meta', 'output_index', 'namespace', 'alias_purpose', + * 'alias_target_id', 'outcome', 'concurrent_searches', and 'items_per_search'. + * + * @param {keyof PrebuiltRuleAsset} fieldName - The field name to check for upgrade status. + * @param {RuleTriad} upgradeableRule - The rule object containing current and target versions. + * + * @returns {GetFieldPredefinedValueReturnType} An object indicating whether the field + * is upgradeable and its value to upgrade to if it's not upgradeable via API. + */ +export const getFieldPredefinedValue = ( + fieldName: keyof PrebuiltRuleAsset, + upgradeableRule: RuleTriad +): GetFieldPredefinedValueReturnType => { + if ( + NON_UPGRADEABLE_DIFFABLE_FIELDS.includes( + fieldName as (typeof NON_UPGRADEABLE_DIFFABLE_FIELDS)[number] + ) + ) { + return { + type: 'PREDEFINED_VALUE', + value: upgradeableRule.target[fieldName], + }; + } + + if ( + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.includes( + fieldName as (typeof FIELDS_TO_UPGRADE_TO_CURRENT_VERSION)[number] + ) + ) { + return { + type: 'PREDEFINED_VALUE', + value: upgradeableRule.current[fieldName], + }; + } + + return { + type: 'CUSTOMIZABLE_VALUE', + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.test.ts new file mode 100644 index 0000000000000..5b1c74825102c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getUpgradeableRules } from './get_upgradeable_rules'; +import { ModeEnum, SkipRuleUpgradeReasonEnum } from '../../../../../../common/api/detection_engine'; +import type { + RuleResponse, + RuleUpgradeSpecifier, +} from '../../../../../../common/api/detection_engine'; +import { getPrebuiltRuleMockOfType } from '../../model/rule_assets/prebuilt_rule_asset.mock'; +import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; + +describe('getUpgradeableRules', () => { + const baseRule = getPrebuiltRuleMockOfType('query'); + const createUpgradeableRule = ( + ruleId: string, + currentVersion: number, + targetVersion: number + ): RuleTriad => { + return { + current: { + ...baseRule, + rule_id: ruleId, + version: currentVersion, + revision: 0, + }, + target: { ...baseRule, rule_id: ruleId, version: targetVersion }, + } as RuleTriad; + }; + + const mockUpgradeableRule = createUpgradeableRule('rule-1', 1, 2); + + const mockCurrentRule: RuleResponse = { + ...convertPrebuiltRuleAssetToRuleResponse(baseRule), + rule_id: 'rule-1', + revision: 0, + version: 1, + }; + + describe('ALL_RULES mode', () => { + it('should return all upgradeable rules when in ALL_RULES mode', () => { + const result = getUpgradeableRules({ + rawUpgradeableRules: [mockUpgradeableRule], + currentRules: [mockCurrentRule], + mode: ModeEnum.ALL_RULES, + }); + + expect(result.upgradeableRules).toEqual([mockUpgradeableRule]); + expect(result.fetchErrors).toEqual([]); + expect(result.skippedRules).toEqual([]); + }); + + it('should handle empty upgradeable rules list', () => { + const result = getUpgradeableRules({ + rawUpgradeableRules: [], + currentRules: [], + mode: ModeEnum.ALL_RULES, + }); + + expect(result.upgradeableRules).toEqual([]); + expect(result.fetchErrors).toEqual([]); + expect(result.skippedRules).toEqual([]); + }); + }); + + describe('SPECIFIC_RULES mode', () => { + const mockVersionSpecifier: RuleUpgradeSpecifier = { + rule_id: 'rule-1', + revision: 0, + version: 1, + }; + + it('should return specified upgradeable rules when in SPECIFIC_RULES mode', () => { + const result = getUpgradeableRules({ + rawUpgradeableRules: [mockUpgradeableRule], + currentRules: [mockCurrentRule], + versionSpecifiers: [mockVersionSpecifier], + mode: ModeEnum.SPECIFIC_RULES, + }); + + expect(result.upgradeableRules).toEqual([mockUpgradeableRule]); + expect(result.fetchErrors).toEqual([]); + expect(result.skippedRules).toEqual([]); + }); + + it('should handle rule not found', () => { + const result = getUpgradeableRules({ + rawUpgradeableRules: [mockUpgradeableRule], + currentRules: [mockCurrentRule], + versionSpecifiers: [{ ...mockVersionSpecifier, rule_id: 'nonexistent' }], + mode: ModeEnum.SPECIFIC_RULES, + }); + + expect(result.upgradeableRules).toEqual([mockUpgradeableRule]); + expect(result.fetchErrors).toHaveLength(1); + expect(result.fetchErrors[0].error.message).toContain( + 'Rule with rule_id "nonexistent" and version "1" not found' + ); + expect(result.skippedRules).toEqual([]); + }); + + it('should handle non-upgradeable rule', () => { + const nonUpgradeableRule: RuleResponse = { + ...convertPrebuiltRuleAssetToRuleResponse(baseRule), + rule_id: 'rule-2', + revision: 0, + version: 1, + }; + + const result = getUpgradeableRules({ + rawUpgradeableRules: [mockUpgradeableRule], + currentRules: [mockCurrentRule, nonUpgradeableRule], + versionSpecifiers: [mockVersionSpecifier, { ...mockVersionSpecifier, rule_id: 'rule-2' }], + mode: ModeEnum.SPECIFIC_RULES, + }); + + expect(result.upgradeableRules).toEqual([mockUpgradeableRule]); + expect(result.fetchErrors).toEqual([]); + expect(result.skippedRules).toEqual([ + { rule_id: 'rule-2', reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE }, + ]); + }); + + it('should handle revision mismatch', () => { + const result = getUpgradeableRules({ + rawUpgradeableRules: [mockUpgradeableRule], + currentRules: [mockCurrentRule], + versionSpecifiers: [{ ...mockVersionSpecifier, revision: 1 }], + mode: ModeEnum.SPECIFIC_RULES, + }); + + expect(result.upgradeableRules).toEqual([]); + expect(result.fetchErrors).toHaveLength(1); + expect(result.fetchErrors[0].error.message).toContain( + 'Revision mismatch for rule_id rule-1: expected 0, got 1' + ); + expect(result.skippedRules).toEqual([]); + }); + + it('should handle multiple rules with mixed scenarios', () => { + const mockUpgradeableRule2 = createUpgradeableRule('rule-2', 1, 2); + const mockCurrentRule2: RuleResponse = { + ...convertPrebuiltRuleAssetToRuleResponse(baseRule), + rule_id: 'rule-2', + revision: 0, + version: 1, + }; + const mockCurrentRule3: RuleResponse = { + ...convertPrebuiltRuleAssetToRuleResponse(baseRule), + rule_id: 'rule-3', + revision: 1, + version: 1, + }; + + const result = getUpgradeableRules({ + rawUpgradeableRules: [ + mockUpgradeableRule, + mockUpgradeableRule2, + createUpgradeableRule('rule-3', 1, 2), + ], + currentRules: [mockCurrentRule, mockCurrentRule2, mockCurrentRule3], + versionSpecifiers: [ + mockVersionSpecifier, + { ...mockVersionSpecifier, rule_id: 'rule-2' }, + { ...mockVersionSpecifier, rule_id: 'rule-3', revision: 0 }, + { ...mockVersionSpecifier, rule_id: 'rule-4' }, + { ...mockVersionSpecifier, rule_id: 'rule-5', revision: 1 }, + ], + mode: ModeEnum.SPECIFIC_RULES, + }); + + expect(result.upgradeableRules).toEqual([mockUpgradeableRule, mockUpgradeableRule2]); + expect(result.fetchErrors).toHaveLength(3); + expect(result.fetchErrors[0].error.message).toContain( + 'Revision mismatch for rule_id rule-3: expected 1, got 0' + ); + expect(result.fetchErrors[1].error.message).toContain( + 'Rule with rule_id "rule-4" and version "1" not found' + ); + expect(result.fetchErrors[2].error.message).toContain( + 'Rule with rule_id "rule-5" and version "1" not found' + ); + expect(result.skippedRules).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts new file mode 100644 index 0000000000000..acfdb674c309a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_upgradeable_rules.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + RuleResponse, + RuleUpgradeSpecifier, + SkippedRuleUpgrade, +} from '../../../../../../common/api/detection_engine'; +import { ModeEnum, SkipRuleUpgradeReasonEnum } from '../../../../../../common/api/detection_engine'; +import type { PromisePoolError } from '../../../../../utils/promise_pool'; +import type { Mode } from '../../../../../../common/api/detection_engine/prebuilt_rules'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; + +export const getUpgradeableRules = ({ + rawUpgradeableRules, + currentRules, + versionSpecifiers, + mode, +}: { + rawUpgradeableRules: RuleTriad[]; + currentRules: RuleResponse[]; + versionSpecifiers?: RuleUpgradeSpecifier[]; + mode: Mode; +}) => { + const upgradeableRules = new Map( + rawUpgradeableRules.map((_rule) => [_rule.current.rule_id, _rule]) + ); + const fetchErrors: Array<PromisePoolError<{ rule_id: string }, Error>> = []; + const skippedRules: SkippedRuleUpgrade[] = []; + + if (mode === ModeEnum.SPECIFIC_RULES) { + const installedRuleIds = new Set(currentRules.map((rule) => rule.rule_id)); + const upgradeableRuleIds = new Set(rawUpgradeableRules.map(({ current }) => current.rule_id)); + versionSpecifiers?.forEach((rule) => { + // Check that the requested rule was found + if (!installedRuleIds.has(rule.rule_id)) { + fetchErrors.push({ + error: new Error( + `Rule with rule_id "${rule.rule_id}" and version "${rule.version}" not found` + ), + item: rule, + }); + return; + } + + // Check that the requested rule is upgradeable + if (!upgradeableRuleIds.has(rule.rule_id)) { + skippedRules.push({ + rule_id: rule.rule_id, + reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE, + }); + return; + } + + // Check that rule revisions match (no update slipped in since the user reviewed the list) + const currentRevision = currentRules.find( + (currentRule) => currentRule.rule_id === rule.rule_id + )?.revision; + if (rule.revision !== currentRevision) { + fetchErrors.push({ + error: new Error( + `Revision mismatch for rule_id ${rule.rule_id}: expected ${currentRevision}, got ${rule.revision}` + ), + item: rule, + }); + // Remove the rule from the list of upgradeable rules + if (upgradeableRules.has(rule.rule_id)) { + upgradeableRules.delete(rule.rule_id); + } + } + }); + } + + return { + upgradeableRules: Array.from(upgradeableRules.values()), + fetchErrors, + skippedRules, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_for_field.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_for_field.ts new file mode 100644 index 0000000000000..00de04c291aeb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_for_field.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PickVersionValues, + PerformRuleUpgradeRequestBody, + AllFieldsDiff, +} from '../../../../../../common/api/detection_engine'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; +import { createFieldUpgradeSpecifier } from './create_field_upgrade_specifier'; +import { mapDiffableRuleFieldValueToRuleSchemaFormat } from './diffable_rule_fields_mappings'; +import { getFieldPredefinedValue } from './get_field_predefined_value'; +import { getValueFromRuleTriad, getValueFromMergedVersion } from './get_value_from_rule_version'; + +interface GetValueForFieldArgs { + fieldName: keyof PrebuiltRuleAsset; + upgradeableRule: RuleTriad; + globalPickVersion: PickVersionValues; + requestBody: PerformRuleUpgradeRequestBody; + ruleFieldsDiff: AllFieldsDiff; +} + +export const getValueForField = ({ + fieldName, + upgradeableRule, + globalPickVersion, + requestBody, + ruleFieldsDiff, +}: GetValueForFieldArgs) => { + const fieldStatus = getFieldPredefinedValue(fieldName, upgradeableRule); + + if (fieldStatus.type === 'PREDEFINED_VALUE') { + return fieldStatus.value; + } + + if (requestBody.mode === 'ALL_RULES') { + return globalPickVersion === 'MERGED' + ? getValueFromMergedVersion({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier: { + pick_version: globalPickVersion, + }, + ruleFieldsDiff, + }) + : getValueFromRuleTriad({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier: { + pick_version: globalPickVersion, + }, + }); + } + + // Handle SPECIFIC_RULES mode + const ruleUpgradeSpecifier = requestBody.rules.find( + (r) => r.rule_id === upgradeableRule.target.rule_id + ); + + if (!ruleUpgradeSpecifier) { + throw new Error(`Rule payload for upgradable rule ${upgradeableRule.target.rule_id} not found`); + } + + const fieldUpgradeSpecifier = createFieldUpgradeSpecifier({ + fieldName, + ruleUpgradeSpecifier, + targetRuleType: upgradeableRule.target.type, + globalPickVersion, + }); + + if (fieldUpgradeSpecifier.pick_version === 'RESOLVED') { + const resolvedValue = fieldUpgradeSpecifier.resolved_value; + + return mapDiffableRuleFieldValueToRuleSchemaFormat(fieldName, resolvedValue); + } + + return fieldUpgradeSpecifier.pick_version === 'MERGED' + ? getValueFromMergedVersion({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier, + ruleFieldsDiff, + }) + : getValueFromRuleTriad({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_from_rule_version.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_from_rule_version.ts new file mode 100644 index 0000000000000..3bef2ea7c742c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/get_value_from_rule_version.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + RuleFieldsToUpgrade, + AllFieldsDiff, +} from '../../../../../../common/api/detection_engine'; +import { RULE_DEFAULTS } from '../../../rule_management/logic/detection_rules_client/mergers/apply_rule_defaults'; +import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; +import type { RuleTriad } from '../../model/rule_groups/get_rule_groups'; +import { + mapRuleFieldToDiffableRuleField, + mapDiffableRuleFieldValueToRuleSchemaFormat, +} from './diffable_rule_fields_mappings'; + +const RULE_DEFAULTS_FIELDS_SET = new Set(Object.keys(RULE_DEFAULTS)); + +export const getValueFromMergedVersion = ({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier, + ruleFieldsDiff, +}: { + fieldName: keyof PrebuiltRuleAsset; + upgradeableRule: RuleTriad; + fieldUpgradeSpecifier: NonNullable<RuleFieldsToUpgrade[keyof RuleFieldsToUpgrade]>; + ruleFieldsDiff: AllFieldsDiff; +}) => { + const ruleId = upgradeableRule.target.rule_id; + const diffableRuleFieldName = mapRuleFieldToDiffableRuleField({ + ruleType: upgradeableRule.target.type, + fieldName, + }); + + if (fieldUpgradeSpecifier.pick_version === 'MERGED') { + const ruleFieldDiff = ruleFieldsDiff[diffableRuleFieldName]; + + if (ruleFieldDiff && ruleFieldDiff.conflict !== 'NONE') { + throw new Error( + `Automatic merge calculation for field '${diffableRuleFieldName}' in rule of rule_id ${ruleId} resulted in a conflict. Please resolve the conflict manually or choose another value for 'pick_version'.` + ); + } + + const mergedVersion = ruleFieldDiff.merged_version; + + return mapDiffableRuleFieldValueToRuleSchemaFormat(fieldName, mergedVersion); + } +}; + +export const getValueFromRuleTriad = ({ + fieldName, + upgradeableRule, + fieldUpgradeSpecifier, +}: { + fieldName: keyof PrebuiltRuleAsset; + upgradeableRule: RuleTriad; + fieldUpgradeSpecifier: NonNullable<RuleFieldsToUpgrade[keyof RuleFieldsToUpgrade]>; +}) => { + const ruleId = upgradeableRule.target.rule_id; + const diffableRuleFieldName = mapRuleFieldToDiffableRuleField({ + ruleType: upgradeableRule.target.type, + fieldName, + }); + + const pickVersion = fieldUpgradeSpecifier.pick_version.toLowerCase() as keyof RuleTriad; + + // By this point, can be only 'base', 'current' or 'target' + const ruleVersion = upgradeableRule[pickVersion]; + + if (!ruleVersion) { + // Current and target versions should always be present + // but base version might not; throw if version is missing. + throw new Error( + `Missing '${pickVersion}' version for field '${diffableRuleFieldName}' in rule ${ruleId}` + ); + } + + // No need for conversions in the field names here since the rule versions in + // UpgradableRule have the values in the 'non-grouped' PrebuiltRuleAsset schema format. + const nonResolvedValue = ruleVersion[fieldName]; + + // If there's no value for the field in the rule versions, check if the field + // requires a default value for it. If it does, return the default value. + if (nonResolvedValue === undefined && RULE_DEFAULTS_FIELDS_SET.has(fieldName)) { + return RULE_DEFAULTS[fieldName as keyof typeof RULE_DEFAULTS]; + } + + // Otherwise, return the non-resolved value, which might be undefined. + return nonResolvedValue; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts index f95189d6af34d..085c41db3a5db 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_route.ts @@ -10,16 +10,10 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { PERFORM_RULE_UPGRADE_URL, PerformRuleUpgradeRequestBody, - PickVersionValuesEnum, - SkipRuleUpgradeReasonEnum, + ModeEnum, } from '../../../../../../common/api/detection_engine/prebuilt_rules'; -import type { - PerformRuleUpgradeResponseBody, - SkippedRuleUpgrade, -} from '../../../../../../common/api/detection_engine/prebuilt_rules'; -import { assertUnreachable } from '../../../../../../common/utility_types'; +import type { PerformRuleUpgradeResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; -import type { PromisePoolError } from '../../../../../utils/promise_pool'; import { buildSiemResponse } from '../../../routes/utils'; import { aggregatePrebuiltRuleErrors } from '../../logic/aggregate_prebuilt_rule_errors'; import { performTimelinesInstallation } from '../../logic/perform_timelines_installation'; @@ -27,9 +21,10 @@ import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { upgradePrebuiltRules } from '../../logic/rule_objects/upgrade_prebuilt_rules'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; -import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; -import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; +import { getUpgradeableRules } from './get_upgradeable_rules'; +import { createModifiedPrebuiltRuleAssets } from './create_upgradeable_rules_payload'; +import { getRuleGroups } from '../../model/rule_groups/get_rule_groups'; export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => { router.versioned @@ -63,108 +58,35 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient); const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient); - const { mode, pick_version: globalPickVersion = PickVersionValuesEnum.TARGET } = - request.body; - - const fetchErrors: Array<PromisePoolError<{ rule_id: string }>> = []; - const targetRules: PrebuiltRuleAsset[] = []; - const skippedRules: SkippedRuleUpgrade[] = []; + const { mode } = request.body; - const versionSpecifiers = mode === 'ALL_RULES' ? undefined : request.body.rules; - const versionSpecifiersMap = new Map( - versionSpecifiers?.map((rule) => [rule.rule_id, rule]) - ); - const ruleVersionsMap = await fetchRuleVersionsTriad({ + const versionSpecifiers = mode === ModeEnum.ALL_RULES ? undefined : request.body.rules; + const ruleTriadsMap = await fetchRuleVersionsTriad({ ruleAssetsClient, ruleObjectsClient, versionSpecifiers, }); - const versionBuckets = getVersionBuckets(ruleVersionsMap); - const { currentRules } = versionBuckets; - // The upgradeable rules list is mutable; we can remove rules from it because of version mismatch - let upgradeableRules = versionBuckets.upgradeableRules; + const ruleGroups = getRuleGroups(ruleTriadsMap); - // Perform all the checks we can before we start the upgrade process - if (mode === 'SPECIFIC_RULES') { - const installedRuleIds = new Set(currentRules.map((rule) => rule.rule_id)); - const upgradeableRuleIds = new Set( - upgradeableRules.map(({ current }) => current.rule_id) - ); - request.body.rules.forEach((rule) => { - // Check that the requested rule was found - if (!installedRuleIds.has(rule.rule_id)) { - fetchErrors.push({ - error: new Error( - `Rule with ID "${rule.rule_id}" and version "${rule.version}" not found` - ), - item: rule, - }); - return; - } - - // Check that the requested rule is upgradeable - if (!upgradeableRuleIds.has(rule.rule_id)) { - skippedRules.push({ - rule_id: rule.rule_id, - reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE, - }); - return; - } - - // Check that rule revisions match (no update slipped in since the user reviewed the list) - const currentRevision = ruleVersionsMap.get(rule.rule_id)?.current?.revision; - if (rule.revision !== currentRevision) { - fetchErrors.push({ - error: new Error( - `Revision mismatch for rule ID ${rule.rule_id}: expected ${rule.revision}, got ${currentRevision}` - ), - item: rule, - }); - // Remove the rule from the list of upgradeable rules - upgradeableRules = upgradeableRules.filter( - ({ current }) => current.rule_id !== rule.rule_id - ); - } - }); - } + const { upgradeableRules, skippedRules, fetchErrors } = getUpgradeableRules({ + rawUpgradeableRules: ruleGroups.upgradeableRules, + currentRules: ruleGroups.currentRules, + versionSpecifiers, + mode, + }); - // Construct the list of target rule versions - upgradeableRules.forEach(({ current, target }) => { - const rulePickVersion = - versionSpecifiersMap?.get(current.rule_id)?.pick_version ?? globalPickVersion; - switch (rulePickVersion) { - case PickVersionValuesEnum.BASE: - const baseVersion = ruleVersionsMap.get(current.rule_id)?.base; - if (baseVersion) { - targetRules.push({ ...baseVersion, version: target.version }); - } else { - fetchErrors.push({ - error: new Error(`Could not find base version for rule ${current.rule_id}`), - item: current, - }); - } - break; - case PickVersionValuesEnum.CURRENT: - targetRules.push({ ...current, version: target.version }); - break; - case PickVersionValuesEnum.TARGET: - targetRules.push(target); - break; - case PickVersionValuesEnum.MERGED: - // TODO: Implement functionality to handle MERGED - targetRules.push(target); - break; - default: - assertUnreachable(rulePickVersion); + const { modifiedPrebuiltRuleAssets, processingErrors } = createModifiedPrebuiltRuleAssets( + { + upgradeableRules, + requestBody: request.body, } - }); + ); - // Perform the upgrade const { results: updatedRules, errors: installationErrors } = await upgradePrebuiltRules( detectionRulesClient, - targetRules + modifiedPrebuiltRuleAssets ); - const ruleErrors = [...fetchErrors, ...installationErrors]; + const ruleErrors = [...fetchErrors, ...processingErrors, ...installationErrors]; const { error: timelineInstallationError } = await performTimelinesInstallation( ctx.securitySolution diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts index ec3ca342bf8c9..00fc5e2beb5b8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_installation/review_rule_installation_route.ts @@ -17,9 +17,9 @@ import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; -import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; +import { getRuleGroups } from '../../model/rule_groups/get_rule_groups'; export const reviewRuleInstallationRoute = (router: SecuritySolutionPluginRouter) => { router.versioned @@ -52,7 +52,7 @@ export const reviewRuleInstallationRoute = (router: SecuritySolutionPluginRouter ruleAssetsClient, ruleObjectsClient, }); - const { installableRules } = getVersionBuckets(ruleVersionsMap); + const { installableRules } = getRuleGroups(ruleVersionsMap); const body: ReviewRuleInstallationResponseBody = { stats: calculateRuleStats(installableRules), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts index 8b229c6406b10..382ec27a1bf35 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts @@ -26,9 +26,9 @@ import { calculateRuleDiff } from '../../logic/diff/calculate_rule_diff'; import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client'; import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client'; import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad'; -import { getVersionBuckets } from '../../model/rule_versions/get_version_buckets'; import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response'; import { PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS } from '../../constants'; +import { getRuleGroups } from '../../model/rule_groups/get_rule_groups'; export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => { router.versioned @@ -61,7 +61,7 @@ export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => ruleAssetsClient, ruleObjectsClient, }); - const { upgradeableRules } = getVersionBuckets(ruleVersionsMap); + const { upgradeableRules } = getRuleGroups(ruleVersionsMap); const ruleDiffCalculationResults = upgradeableRules.map(({ current }) => { const ruleVersions = ruleVersionsMap.get(current.rule_id); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client.ts index 3daaab8ecf10f..0dbfd8a230a5a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client.ts @@ -13,7 +13,7 @@ import { withSecuritySpan } from '../../../../../utils/with_security_span'; import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset'; import { validatePrebuiltRuleAssets } from './prebuilt_rule_assets_validation'; import { PREBUILT_RULE_ASSETS_SO_TYPE } from './prebuilt_rule_assets_type'; -import type { RuleVersionSpecifier } from '../../model/rule_versions/rule_version_specifier'; +import type { RuleVersionSpecifier } from '../rule_versions/rule_version_specifier'; const MAX_PREBUILT_RULES_COUNT = 10_000; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad.ts index ae7bdc6b391b4..11a5660e77a31 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/fetch_rule_versions_triad.ts @@ -8,7 +8,7 @@ import type { RuleVersions } from '../diff/calculate_rule_diff'; import type { IPrebuiltRuleAssetsClient } from '../rule_assets/prebuilt_rule_assets_client'; import type { IPrebuiltRuleObjectsClient } from '../rule_objects/prebuilt_rule_objects_client'; -import type { RuleVersionSpecifier } from '../../model/rule_versions/rule_version_specifier'; +import type { RuleVersionSpecifier } from './rule_version_specifier'; import { zipRuleVersions } from './zip_rule_versions'; interface GetRuleVersionsMapArgs { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_versions/rule_version_specifier.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/rule_version_specifier.ts similarity index 100% rename from x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_versions/rule_version_specifier.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/rule_versions/rule_version_specifier.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts index 8f9c1a6a32357..6442582c1b573 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts @@ -4,11 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { z } from '@kbn/zod'; +import type { + EqlRuleCreateFields, + QueryRuleCreateFields, + SavedQueryRuleCreateFields, + ThresholdRuleCreateFields, + ThreatMatchRuleCreateFields, + MachineLearningRuleCreateFields, + NewTermsRuleCreateFields, + EsqlRuleCreateFields, + TypeSpecificCreatePropsInternal, +} from '../../../../../../common/api/detection_engine'; +import { PrebuiltRuleAsset, type PrebuiltAssetBaseProps } from './prebuilt_rule_asset'; -import type { PrebuiltRuleAsset } from './prebuilt_rule_asset'; +type TypeSpecificCreateProps = z.infer<typeof TypeSpecificCreatePropsInternal>; -export const getPrebuiltRuleMock = (rewrites?: Partial<PrebuiltRuleAsset>): PrebuiltRuleAsset => - ({ +export const getPrebuiltRuleMock = (rewrites?: Partial<PrebuiltRuleAsset>): PrebuiltRuleAsset => { + return PrebuiltRuleAsset.parse({ description: 'some description', name: 'Query with a rule id', query: 'user.name: root or user.name: admin', @@ -19,40 +32,42 @@ export const getPrebuiltRuleMock = (rewrites?: Partial<PrebuiltRuleAsset>): Preb rule_id: 'rule-1', version: 1, author: [], + license: 'Elastic License v2', ...rewrites, - } as PrebuiltRuleAsset); + }); +}; -export const getPrebuiltRuleWithExceptionsMock = (): PrebuiltRuleAsset => ({ - description: 'A rule with an exception list', - name: 'A rule with an exception list', - query: 'user.name: root or user.name: admin', - severity: 'high', +export const getPrebuiltQueryRuleSpecificFieldsMock = (): QueryRuleCreateFields => ({ type: 'query', - risk_score: 42, + query: 'user.name: root or user.name: admin', language: 'kuery', - rule_id: 'rule-with-exceptions', - exceptions_list: [ - { - id: 'endpoint_list', - list_id: 'endpoint_list', - namespace_type: 'agnostic', - type: 'endpoint', - }, - ], - version: 2, }); -export const getPrebuiltThreatMatchRuleMock = (): PrebuiltRuleAsset => ({ - description: 'some description', - name: 'Query with a rule id', +export const getPrebuiltEqlRuleSpecificFieldsMock = (): EqlRuleCreateFields => ({ + type: 'eql', + query: 'process where process.name == "cmd.exe"', + language: 'eql', +}); + +export const getPrebuiltSavedQueryRuleSpecificFieldsMock = (): SavedQueryRuleCreateFields => ({ + type: 'saved_query', + saved_id: 'saved-query-id', +}); + +export const getPrebuiltThresholdRuleSpecificFieldsMock = (): ThresholdRuleCreateFields => ({ + type: 'threshold', query: 'user.name: root or user.name: admin', - severity: 'high', + language: 'kuery', + threshold: { + field: 'user.name', + value: 5, + }, +}); + +export const getPrebuiltThreatMatchRuleSpecificFieldsMock = (): ThreatMatchRuleCreateFields => ({ type: 'threat_match', - risk_score: 55, + query: 'user.name: root or user.name: admin', language: 'kuery', - rule_id: 'rule-1', - version: 1, - author: [], threat_query: '*:*', threat_index: ['list-index'], threat_mapping: [ @@ -66,22 +81,115 @@ export const getPrebuiltThreatMatchRuleMock = (): PrebuiltRuleAsset => ({ ], }, ], - threat_filters: [ - { - bool: { - must: [ - { - query_string: { - query: 'host.name: linux', - analyze_wildcard: true, - time_zone: 'Zulu', - }, - }, - ], - filter: [], - should: [], - must_not: [], - }, - }, - ], + concurrent_searches: 2, + items_per_search: 10, }); + +export const getPrebuiltThreatMatchRuleMock = (): PrebuiltRuleAsset => ({ + description: 'some description', + name: 'Query with a rule id', + severity: 'high', + risk_score: 55, + rule_id: 'rule-1', + version: 1, + author: [], + license: 'Elastic License v2', + ...getPrebuiltThreatMatchRuleSpecificFieldsMock(), +}); + +export const getPrebuiltMachineLearningRuleSpecificFieldsMock = + (): MachineLearningRuleCreateFields => ({ + type: 'machine_learning', + anomaly_threshold: 50, + machine_learning_job_id: 'ml-job-id', + }); + +export const getPrebuiltNewTermsRuleSpecificFieldsMock = (): NewTermsRuleCreateFields => ({ + type: 'new_terms', + query: 'user.name: *', + language: 'kuery', + new_terms_fields: ['user.name'], + history_window_start: '1h', +}); + +export const getPrebuiltEsqlRuleSpecificFieldsMock = (): EsqlRuleCreateFields => ({ + type: 'esql', + query: 'from process where process.name == "cmd.exe"', + language: 'esql', +}); + +export const getPrebuiltRuleMockOfType = <T extends TypeSpecificCreateProps>( + type: T['type'] +): PrebuiltAssetBaseProps & + Extract<TypeSpecificCreateProps, T> & { version: number; rule_id: string } => { + let typeSpecificFields: TypeSpecificCreateProps; + + switch (type) { + case 'query': + typeSpecificFields = getPrebuiltQueryRuleSpecificFieldsMock(); + break; + case 'eql': + typeSpecificFields = getPrebuiltEqlRuleSpecificFieldsMock(); + break; + case 'saved_query': + typeSpecificFields = getPrebuiltSavedQueryRuleSpecificFieldsMock(); + break; + case 'threshold': + typeSpecificFields = getPrebuiltThresholdRuleSpecificFieldsMock(); + break; + case 'threat_match': + typeSpecificFields = getPrebuiltThreatMatchRuleSpecificFieldsMock(); + break; + case 'machine_learning': + typeSpecificFields = getPrebuiltMachineLearningRuleSpecificFieldsMock(); + break; + case 'new_terms': + typeSpecificFields = getPrebuiltNewTermsRuleSpecificFieldsMock(); + break; + case 'esql': + typeSpecificFields = getPrebuiltEsqlRuleSpecificFieldsMock(); + break; + default: + throw new Error(`Unsupported rule type: ${type}`); + } + + return { + tags: ['tag1', 'tag2'], + description: 'some description', + name: `${type} rule`, + severity: 'high', + risk_score: 55, + author: [], + license: 'Elastic License v2', + ...typeSpecificFields, + rule_id: `rule-${type}`, + version: 1, + }; +}; + +export const getPrebuiltRuleWithExceptionsMock = ( + rewrites?: Partial<PrebuiltRuleAsset> +): PrebuiltRuleAsset => { + const parsedFields = rewrites ? PrebuiltRuleAsset.parse(rewrites) : {}; + + return { + description: 'A rule with an exception list', + name: 'A rule with an exception list', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'query', + risk_score: 42, + language: 'kuery', + rule_id: 'rule-with-exceptions', + exceptions_list: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: 'endpoint', + }, + ], + version: 2, + ...parsedFields, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts index cc7e38632547f..8069ee0385eb7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts @@ -20,7 +20,6 @@ function zodMaskFor<T>() { return Object.assign({}, ...propObjects); }; } - /** * The PrebuiltRuleAsset schema is created based on the rule schema defined in our OpenAPI specs. * However, we don't need all the rule schema fields to be present in the PrebuiltRuleAsset. @@ -39,6 +38,7 @@ const BASE_PROPS_REMOVED_FROM_PREBUILT_RULE_ASSET = zodMaskFor<BaseCreateProps>( 'outcome', ]); +export type PrebuiltAssetBaseProps = z.infer<typeof PrebuiltAssetBaseProps>; export const PrebuiltAssetBaseProps = BaseCreateProps.omit( BASE_PROPS_REMOVED_FROM_PREBUILT_RULE_ASSET ); @@ -65,31 +65,3 @@ export const PrebuiltRuleAsset = PrebuiltAssetBaseProps.and(TypeSpecificCreatePr version: RuleVersion, }) ); - -function createUpgradableRuleFieldsPayloadByType() { - const baseFields = Object.keys(PrebuiltAssetBaseProps.shape); - - return new Map( - TypeSpecificCreatePropsInternal.options.map((option) => { - const typeName = option.shape.type.value; - const typeSpecificFieldsForType = Object.keys(option.shape); - - return [typeName, [...baseFields, ...typeSpecificFieldsForType]]; - }) - ); -} - -/** - * Map of the fields payloads to be passed to the `upgradePrebuiltRules()` method during the - * Upgrade workflow (`/upgrade/_perform` endpoint) by type. - * - * Creating this Map dynamically, based on BaseCreateProps and TypeSpecificFields, ensures that we don't need to: - * - manually add rule types to this Map if they are created - * - manually add or remove any fields if they are added or removed to a specific rule type - * - manually add or remove any fields if we decide that they should not be part of the upgradable fields. - * - * Notice that this Map includes, for each rule type, all fields that are part of the BaseCreateProps and all fields that - * are part of the TypeSpecificFields, including those that are not part of RuleUpgradeSpecifierFields schema, where - * the user of the /upgrade/_perform endpoint can specify which fields to upgrade during the upgrade workflow. - */ -export const UPGRADABLE_FIELDS_PAYLOAD_BY_RULE_TYPE = createUpgradableRuleFieldsPayloadByType(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_versions/get_version_buckets.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_groups/get_rule_groups.ts similarity index 75% rename from x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_versions/get_version_buckets.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_groups/get_rule_groups.ts index 0c541c0ae00ff..c9adf6db850fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_versions/get_version_buckets.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_groups/get_rule_groups.ts @@ -9,7 +9,21 @@ import type { RuleResponse } from '../../../../../../common/api/detection_engine import type { RuleVersions } from '../../logic/diff/calculate_rule_diff'; import type { PrebuiltRuleAsset } from '../rule_assets/prebuilt_rule_asset'; -export interface VersionBuckets { +export interface RuleTriad { + /** + * The base version of the rule (no customizations) + */ + base?: PrebuiltRuleAsset; + /** + * The currently installed version + */ + current: RuleResponse; + /** + * The latest available version + */ + target: PrebuiltRuleAsset; +} +export interface RuleGroups { /** * Rules that are currently installed in Kibana */ @@ -21,16 +35,7 @@ export interface VersionBuckets { /** * Rules that are installed but outdated */ - upgradeableRules: Array<{ - /** - * The currently installed version - */ - current: RuleResponse; - /** - * The latest available version - */ - target: PrebuiltRuleAsset; - }>; + upgradeableRules: RuleTriad[]; /** * All available rules * (installed and not installed) @@ -38,13 +43,13 @@ export interface VersionBuckets { totalAvailableRules: PrebuiltRuleAsset[]; } -export const getVersionBuckets = (ruleVersionsMap: Map<string, RuleVersions>): VersionBuckets => { +export const getRuleGroups = (ruleVersionsMap: Map<string, RuleVersions>): RuleGroups => { const currentRules: RuleResponse[] = []; const installableRules: PrebuiltRuleAsset[] = []; const totalAvailableRules: PrebuiltRuleAsset[] = []; - const upgradeableRules: VersionBuckets['upgradeableRules'] = []; + const upgradeableRules: RuleGroups['upgradeableRules'] = []; - ruleVersionsMap.forEach(({ current, target }) => { + ruleVersionsMap.forEach(({ base, current, target }) => { if (target != null) { // If this rule is available in the package totalAvailableRules.push(target); @@ -63,6 +68,7 @@ export const getVersionBuckets = (ruleVersionsMap: Map<string, RuleVersions>): V if (current != null && target != null && current.version < target.version) { // If this rule is installed but outdated upgradeableRules.push({ + base, current, target, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts index ba21037ba376f..becc68f3d0075 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts @@ -85,6 +85,8 @@ export const applyRulePatch = async ({ from: rulePatch.from ?? existingRule.from, license: rulePatch.license ?? existingRule.license, output_index: rulePatch.output_index ?? existingRule.output_index, + alias_purpose: rulePatch.alias_purpose ?? existingRule.alias_purpose, + alias_target_id: rulePatch.alias_target_id ?? existingRule.alias_target_id, timeline_id: rulePatch.timeline_id ?? existingRule.timeline_id, timeline_title: rulePatch.timeline_title ?? existingRule.timeline_title, meta: rulePatch.meta ?? existingRule.meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts index ee5686e96d130..64486bed14304 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/upgrade_prebuilt_rule.ts @@ -14,7 +14,7 @@ import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules'; import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; -import { applyRulePatch } from '../mergers/apply_rule_patch'; +import { applyRuleUpdate } from '../mergers/apply_rule_update'; import { ClientError, validateMlAuth } from '../utils'; import { createRule } from './create_rule'; import { getRuleByRuleId } from './get_rule_by_rule_id'; @@ -68,17 +68,17 @@ export const upgradePrebuiltRule = async ({ return createdRule; } - // Else, simply patch it. - const patchedRule = await applyRulePatch({ + // Else, recreate the rule from scratch with the passed payload. + const updatedRule = await applyRuleUpdate({ prebuiltRuleAssetClient, existingRule, - rulePatch: ruleAsset, + ruleUpdate: ruleAsset, }); - const patchedInternalRule = await rulesClient.update({ + const updatedInternalRule = await rulesClient.update({ id: existingRule.id, - data: convertRuleResponseToAlertingRule(patchedRule, actionsClient), + data: convertRuleResponseToAlertingRule(updatedRule, actionsClient), }); - return convertAlertingRuleToRuleResponse(patchedInternalRule); + return convertAlertingRuleToRuleResponse(updatedInternalRule); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/update_actions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/update_actions.ts index 40a967c068a00..93deebb4ad7d9 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/update_actions.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/actions/trial_license_complete_tier/update_actions.ts @@ -39,6 +39,20 @@ export default ({ getService }: FtrProviderContext) => { describe('@serverless @ess update_actions', () => { describe('updating actions', () => { + before(async () => { + await es.indices.delete({ index: 'logs-test', ignore_unavailable: true }); + await es.indices.create({ + index: 'logs-test', + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + }, + }, + }); + }); + beforeEach(async () => { await deleteAllAlerts(supertest, log, es); await deleteAllRules(supertest, log); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/get_prebuilt_rules_status.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/get_prebuilt_rules_status.ts index 3c5806688cd61..03772258bd679 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/get_prebuilt_rules_status.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/get_prebuilt_rules_status.ts @@ -14,7 +14,7 @@ import { createRuleAssetSavedObject, createPrebuiltRuleAssetSavedObjects, installPrebuiltRules, - upgradePrebuiltRules, + performUpgradePrebuiltRules, createHistoricalPrebuiltRuleAssetSavedObjects, getPrebuiltRulesAndTimelinesStatus, installPrebuiltRulesAndTimelines, @@ -136,8 +136,11 @@ export default ({ getService }: FtrProviderContext): void => { // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - // Upgrade all rules - await upgradePrebuiltRules(es, supertest); + // Upgrade all rules to target version + await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ @@ -270,8 +273,11 @@ export default ({ getService }: FtrProviderContext): void => { createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), ]); - // Upgrade the rule - await upgradePrebuiltRules(es, supertest); + // Upgrade the rule to target version + await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts index 72707393c0527..46db3e2602702 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/index.ts @@ -17,6 +17,8 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./upgrade_prebuilt_rules')); loadTestFile(require.resolve('./upgrade_prebuilt_rules_with_historical_versions')); loadTestFile(require.resolve('./fleet_integration')); + loadTestFile(require.resolve('./upgrade_perform_prebuilt_rules.all_rules_mode')); + loadTestFile(require.resolve('./upgrade_perform_prebuilt_rules.specific_rules_mode')); loadTestFile(require.resolve('./upgrade_review_prebuilt_rules.rule_type_fields')); loadTestFile(require.resolve('./upgrade_review_prebuilt_rules.number_fields')); loadTestFile(require.resolve('./upgrade_review_prebuilt_rules.single_line_string_fields')); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.all_rules_mode.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.all_rules_mode.ts new file mode 100644 index 0000000000000..2d0fe71e7d5d4 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.all_rules_mode.ts @@ -0,0 +1,490 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; +import type SuperTest from 'supertest'; +import { cloneDeep } from 'lodash'; +import { + QueryRuleCreateFields, + EqlRuleCreateFields, + EsqlRuleCreateFields, + RuleResponse, + ThreatMatchRuleCreateFields, + ThreatMatchRule, + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION, + ModeEnum, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { PrebuiltRuleAsset } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObjectOfType, + installPrebuiltRules, + performUpgradePrebuiltRules, + patchRule, + createHistoricalPrebuiltRuleAssetSavedObjects, + reviewPrebuiltRulesToUpgrade, + getInstalledRules, + createRuleAssetSavedObject, + getWebHookAction, +} from '../../../../utils'; +import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + const securitySolutionApi = getService('securitySolutionApi'); + + describe('@ess @serverless @skipInServerlessMKI Perform Prebuilt Rules Upgrades - mode: ALL_RULES', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + const CURRENT_NAME = 'My current name'; + const CURRENT_TAGS = ['current', 'tags']; + const TARGET_NAME = 'My target name'; + const TARGET_TAGS = ['target', 'tags']; + + describe(`successful updates`, () => { + const queryRule = createRuleAssetSavedObjectOfType<QueryRuleCreateFields>('query'); + const eqlRule = createRuleAssetSavedObjectOfType<EqlRuleCreateFields>('eql'); + const esqlRule = createRuleAssetSavedObjectOfType<EsqlRuleCreateFields>('esql'); + + const basePrebuiltAssets = [queryRule, eqlRule, esqlRule]; + const basePrebuiltAssetsMap = createIdToRuleMap( + basePrebuiltAssets.map((r) => r['security-rule']) + ); + + const targetPrebuiltAssets = basePrebuiltAssets.map((ruleAssetSavedObject) => { + const targetObject = cloneDeep(ruleAssetSavedObject); + targetObject['security-rule'].version += 1; + targetObject['security-rule'].name = TARGET_NAME; + targetObject['security-rule'].tags = TARGET_TAGS; + + return targetObject; + }); + + it('upgrades all upgreadeable rules fields to their BASE versions', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + // Perform the upgrade, all rules' fields to their BASE versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'BASE', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(3); + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const matchingBaseAsset = basePrebuiltAssetsMap.get(updatedRule.rule_id); + if (!matchingBaseAsset) { + throw new Error(`Could not find matching base asset for rule ${updatedRule.rule_id}`); + } + + // Rule Version should be incremented by 1 + // Rule Name and Tags should match the base asset's values, not the Target asset's values + expect(updatedRule.version).toEqual(matchingBaseAsset.version + 1); + expect(updatedRule.name).toEqual(matchingBaseAsset.name); + expect(updatedRule.tags).toEqual(matchingBaseAsset.tags); + }); + + // Get installed rules + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + for (const [ruleId, installedRule] of installedRulesMap) { + const matchingBaseAsset = basePrebuiltAssetsMap.get(ruleId); + expect(installedRule.name).toEqual(matchingBaseAsset?.name); + expect(installedRule.tags).toEqual(matchingBaseAsset?.tags); + } + }); + + it('upgrades all upgreadeable rules fields to their CURRENT versions', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Patch all 3 installed rules to create a current version for each + for (const baseRule of basePrebuiltAssets) { + await patchRule(supertest, log, { + rule_id: baseRule['security-rule'].rule_id, + name: CURRENT_NAME, + tags: CURRENT_TAGS, + }); + } + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + // Perform the upgrade, all rules' fields to their CURRENT versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'CURRENT', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(3); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const matchingBaseAsset = basePrebuiltAssetsMap.get(updatedRule.rule_id); + // Rule Version should be incremented by 1 + // Rule Query should match the current's version query + if (matchingBaseAsset) { + expect(updatedRule.version).toEqual(matchingBaseAsset.version + 1); + expect(updatedRule.name).toEqual(CURRENT_NAME); + expect(updatedRule.tags).toEqual(CURRENT_TAGS); + } else { + throw new Error(`Matching base asset not found for rule_id: ${updatedRule.rule_id}`); + } + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + for (const [_, installedRule] of installedRulesMap) { + expect(installedRule.name).toEqual(CURRENT_NAME); + expect(installedRule.tags).toEqual(CURRENT_TAGS); + } + }); + + it('upgrades all upgreadeable rules fields to their TARGET versions', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Patch all 3 installed rules to create a current version for each + for (const baseRule of basePrebuiltAssets) { + await patchRule(supertest, log, { + rule_id: baseRule['security-rule'].rule_id, + query: CURRENT_NAME, + tags: CURRENT_TAGS, + }); + } + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + // Perform the upgrade, all rules' fields to their CURRENT versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'TARGET', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(3); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const matchingBaseAsset = basePrebuiltAssetsMap.get(updatedRule.rule_id); + + // Rule Version should be incremented by 1 + // Rule Query should match the current's version query + if (matchingBaseAsset) { + expect(updatedRule.version).toEqual(matchingBaseAsset.version + 1); + expect(updatedRule.name).toEqual(TARGET_NAME); + expect(updatedRule.tags).toEqual(TARGET_TAGS); + } else { + throw new Error(`Matching base asset not found for rule_id: ${updatedRule.rule_id}`); + } + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + for (const [_, installedRule] of installedRulesMap) { + expect(installedRule.name).toEqual(TARGET_NAME); + expect(installedRule.tags).toEqual(TARGET_TAGS); + } + }); + + it('upgrades all upgreadeable rules fields to their MERGED versions', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + // Call the /upgrade/_review endpoint to save the calculated merged_versions + const reviewResponse = await reviewPrebuiltRulesToUpgrade(supertest); + const reviewRuleResponseMap = new Map( + reviewResponse.rules.map((upgradeInfo) => [ + upgradeInfo.rule_id, + { + tags: upgradeInfo.diff.fields.tags?.merged_version, + name: upgradeInfo.diff.fields.name?.merged_version, + }, + ]) + ); + + // Perform the upgrade, all rules' fields to their MERGED versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'MERGED', + }); + const updatedRulesMap = createIdToRuleMap(performUpgradeResponse.results.updated); + + // All upgrades should succeed: neither query nor tags should have a merge conflict + expect(performUpgradeResponse.summary.succeeded).toEqual(3); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + for (const [ruleId, installedRule] of installedRulesMap) { + expect(installedRule.name).toEqual(updatedRulesMap.get(ruleId)?.name); + expect(installedRule.name).toEqual(reviewRuleResponseMap.get(ruleId)?.name); + expect(installedRule.tags).toEqual(updatedRulesMap.get(ruleId)?.tags); + expect(installedRule.tags).toEqual(reviewRuleResponseMap.get(ruleId)?.tags); + } + }); + }); + + describe('edge cases and unhappy paths', () => { + const firstQueryRule = createRuleAssetSavedObject({ + type: 'query', + language: 'kuery', + rule_id: 'query-rule-1', + }); + const secondQueryRule = createRuleAssetSavedObject({ + type: 'query', + language: 'kuery', + rule_id: 'query-rule-2', + }); + const eqlRule = createRuleAssetSavedObject({ + type: 'eql', + language: 'eql', + rule_id: 'eql-rule', + }); + + const basePrebuiltAssets = [firstQueryRule, eqlRule, secondQueryRule]; + + it('rejects all updates of rules which have a rule type change if the pick_version is not TARGET', async () => { + // Install base prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Mock a rule type change to 'ml' to the first two rules of the basePrebuiltAssets array + const targetMLPrebuiltAssets = basePrebuiltAssets + .slice(0, 2) + .map((ruleAssetSavedObject) => { + const targetObject = cloneDeep(ruleAssetSavedObject); + + return { + ...targetObject, + ...createRuleAssetSavedObject({ + rule_id: targetObject['security-rule'].rule_id, + version: targetObject['security-rule'].version + 1, + type: 'machine_learning', + machine_learning_job_id: 'job_id', + anomaly_threshold: 1, + }), + }; + }); + + // Mock an normal update of the rule 'query-rule-2', with NO rule type change + const targetAssetSameTypeUpdate = createRuleAssetSavedObject({ + type: 'query', + language: 'kuery', + rule_id: 'query-rule-2', + version: 2, + }); + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + ...targetMLPrebuiltAssets, + targetAssetSameTypeUpdate, + ]); + + // Perform the upgrade, all rules' fields to their BASE versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'BASE', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); // update of same type + expect(performUpgradeResponse.summary.failed).toEqual(2); // updates with rule type change + + expect(performUpgradeResponse.errors).toHaveLength(2); + performUpgradeResponse.errors.forEach((error) => { + const ruleId = error.rules[0].rule_id; + expect(error.message).toContain( + `Rule update for rule ${ruleId} has a rule type change. All 'pick_version' values for rule must match 'TARGET'` + ); + }); + }); + + it('rejects updates of rules with a pick_version of MERGED which have fields which result in conflicts in the three way diff calculations', async () => { + // Install base prebuilt detection rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + // Patch all 3 installed rules to create a current version for each + for (const baseRule of basePrebuiltAssets) { + await patchRule(supertest, log, { + rule_id: baseRule['security-rule'].rule_id, + name: CURRENT_NAME, + tags: CURRENT_TAGS, + }); + } + + const targetPrebuiltAssets = basePrebuiltAssets.map((ruleAssetSavedObject) => { + const targetObject = cloneDeep(ruleAssetSavedObject); + targetObject['security-rule'].version += 1; + targetObject['security-rule'].name = TARGET_NAME; + targetObject['security-rule'].tags = TARGET_TAGS; + + return targetObject; + }); + + // Create new versions of the assets of the installed rules + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + // Perform the upgrade, all rules' fields to their MERGED versions + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'MERGED', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(0); // all rules have conflicts + expect(performUpgradeResponse.summary.failed).toEqual(3); // all rules have conflicts + + performUpgradeResponse.errors.forEach((error) => { + const ruleId = error.rules[0].rule_id; + expect(error.message).toContain( + `Merge conflicts found in rule '${ruleId}' for fields: name, tags. Please resolve the conflict manually or choose another value for 'pick_version'` + ); + }); + }); + + it('preserves FIELDS_TO_UPGRADE_TO_CURRENT_VERSION when upgrading to TARGET version with undefined fields', async () => { + const baseRule = + createRuleAssetSavedObjectOfType<ThreatMatchRuleCreateFields>('threat_match'); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [baseRule]); + await installPrebuiltRules(es, supertest); + + const ruleId = baseRule['security-rule'].rule_id; + + const installedBaseRule = ( + await securitySolutionApi.readRule({ + query: { + rule_id: ruleId, + }, + }) + ).body as ThreatMatchRule; + + // Patch the installed rule to set all FIELDS_TO_UPGRADE_TO_CURRENT_VERSION to some defined value + const currentValues: { [key: string]: unknown } = { + enabled: true, + exceptions_list: [ + { + id: 'test-list', + list_id: 'test-list', + type: 'detection', + namespace_type: 'single', + } as const, + ], + alert_suppression: { + group_by: ['host.name'], + duration: { value: 5, unit: 'm' as const }, + }, + actions: [await createAction(supertest)], + response_actions: [ + { + params: { + command: 'isolate' as const, + comment: 'comment', + }, + action_type_id: '.endpoint' as const, + }, + ], + meta: { some_key: 'some_value' }, + output_index: '.siem-signals-default', + namespace: 'default', + concurrent_searches: 5, + items_per_search: 100, + }; + + await securitySolutionApi.updateRule({ + body: { + ...installedBaseRule, + ...currentValues, + id: undefined, + }, + }); + + // Create a target version with undefined values for these fields + const targetRule = cloneDeep(baseRule); + targetRule['security-rule'].version += 1; + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + // @ts-expect-error + targetRule['security-rule'][field] = undefined; + }); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetRule]); + + // Perform the upgrade + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.ALL_RULES, + pick_version: 'TARGET', + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); + const upgradedRule = performUpgradeResponse.results.updated[0] as ThreatMatchRule; + + // Check that all FIELDS_TO_UPGRADE_TO_CURRENT_VERSION still have their "current" values + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(upgradedRule[field]).toEqual(currentValues[field]); + }); + + // Verify the installed rule + const installedRules = await getInstalledRules(supertest); + const installedRule = installedRules.data.find( + (rule) => rule.rule_id === baseRule['security-rule'].rule_id + ) as ThreatMatchRule; + + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(installedRule[field]).toEqual(currentValues[field]); + }); + }); + }); + }); +}; + +function createIdToRuleMap(rules: Array<PrebuiltRuleAsset | RuleResponse>) { + return new Map(rules.map((rule) => [rule.rule_id, rule])); +} + +async function createAction(supertest: SuperTest.Agent) { + const createConnector = async (payload: Record<string, unknown>) => + (await supertest.post('/api/actions/action').set('kbn-xsrf', 'true').send(payload).expect(200)) + .body; + + const createWebHookConnector = () => createConnector(getWebHookAction()); + + const webHookAction = await createWebHookConnector(); + + const defaultRuleAction = { + id: webHookAction.id, + action_type_id: '.webhook' as const, + group: 'default' as const, + params: { + body: '{"test":"a default action"}', + }, + frequency: { + notifyWhen: 'onThrottleInterval' as const, + summary: true, + throttle: '1h' as const, + }, + uuid: 'd487ec3d-05f2-44ad-8a68-11c97dc92202', + }; + + return defaultRuleAction; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.specific_rules_mode.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.specific_rules_mode.ts new file mode 100644 index 0000000000000..8c086c46927e7 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_perform_prebuilt_rules.specific_rules_mode.ts @@ -0,0 +1,861 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; +import type SuperTest from 'supertest'; +import { cloneDeep } from 'lodash'; +import { + QueryRuleCreateFields, + EqlRuleCreateFields, + EsqlRuleCreateFields, + ThreatMatchRuleCreateFields, + RuleResponse, + ModeEnum, + PickVersionValues, + RuleEqlQuery, + EqlRule, + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { PrebuiltRuleAsset } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules'; +import { ThreatMatchRule } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { FtrProviderContext } from '../../../../../../ftr_provider_context'; +import { + deleteAllTimelines, + deleteAllPrebuiltRuleAssets, + createRuleAssetSavedObjectOfType, + installPrebuiltRules, + performUpgradePrebuiltRules, + patchRule, + getInstalledRules, + createHistoricalPrebuiltRuleAssetSavedObjects, + reviewPrebuiltRulesToUpgrade, + createRuleAssetSavedObject, + getWebHookAction, +} from '../../../../utils'; +import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; + +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const log = getService('log'); + const securitySolutionApi = getService('securitySolutionApi'); + + describe('@ess @serverless @skipInServerlessMKI Perform Prebuilt Rules Upgrades - mode: SPECIFIC_RULES', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); + }); + + const CURRENT_NAME = 'My current name'; + const CURRENT_TAGS = ['current', 'tags']; + const TARGET_NAME = 'My target name'; + const TARGET_TAGS = ['target', 'tags']; + + describe('successful updates', () => { + const queryRule = createRuleAssetSavedObjectOfType<QueryRuleCreateFields>('query'); + const eqlRule = createRuleAssetSavedObjectOfType<EqlRuleCreateFields>('eql'); + const esqlRule = createRuleAssetSavedObjectOfType<EsqlRuleCreateFields>('esql'); + + const basePrebuiltAssets = [queryRule, eqlRule, esqlRule]; + + const targetPrebuiltAssets = basePrebuiltAssets.map((ruleAssetSavedObject) => { + const targetObject = cloneDeep(ruleAssetSavedObject); + targetObject['security-rule'].version += 1; + targetObject['security-rule'].name = TARGET_NAME; + targetObject['security-rule'].tags = TARGET_TAGS; + return targetObject; + }); + + it('upgrades specific rules to their BASE versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const rulesToUpgrade = basePrebuiltAssets.map((rule) => ({ + rule_id: rule['security-rule'].rule_id, + revision: 0, + version: rule['security-rule'].version + 1, + })); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'BASE', + rules: rulesToUpgrade, + }); + + const expectedResults = basePrebuiltAssets.map((asset) => ({ + rule_id: asset['security-rule'].rule_id, + version: asset['security-rule'].version + 1, + name: asset['security-rule'].name, + tags: asset['security-rule'].tags, + })); + + expect(performUpgradeResponse.summary.succeeded).toEqual(basePrebuiltAssets.length); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const expected = expectedResults.find((r) => r.rule_id === updatedRule.rule_id); + expect(updatedRule.version).toEqual(expected?.version); + expect(updatedRule.name).toEqual(expected?.name); + expect(updatedRule.tags).toEqual(expected?.tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + expectedResults.forEach((expected) => { + const installedRule = installedRulesMap.get(expected.rule_id); + expect(installedRule?.name).toEqual(expected.name); + expect(installedRule?.tags).toEqual(expected.tags); + }); + }); + + it('upgrades specific rules to their CURRENT versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + for (const baseRule of basePrebuiltAssets) { + await patchRule(supertest, log, { + rule_id: baseRule['security-rule'].rule_id, + name: CURRENT_NAME, + tags: CURRENT_TAGS, + }); + } + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const rulesToUpgrade = basePrebuiltAssets.map((rule) => ({ + rule_id: rule['security-rule'].rule_id, + revision: 1, + version: rule['security-rule'].version + 1, + })); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'CURRENT', + rules: rulesToUpgrade, + }); + + const expectedResults = basePrebuiltAssets.map((asset) => ({ + rule_id: asset['security-rule'].rule_id, + name: CURRENT_NAME, + tags: CURRENT_TAGS, + })); + + expect(performUpgradeResponse.summary.succeeded).toEqual(basePrebuiltAssets.length); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const expected = expectedResults.find((r) => r.rule_id === updatedRule.rule_id); + expect(updatedRule.name).toEqual(expected?.name); + expect(updatedRule.tags).toEqual(expected?.tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + expectedResults.forEach((expected) => { + const installedRule = installedRulesMap.get(expected.rule_id); + expect(installedRule?.name).toEqual(expected.name); + expect(installedRule?.tags).toEqual(expected.tags); + }); + }); + + it('upgrades specific rules to their TARGET versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const rulesToUpgrade = basePrebuiltAssets.map((rule) => ({ + rule_id: rule['security-rule'].rule_id, + revision: 0, + version: rule['security-rule'].version + 1, + })); + + const expectedResults = basePrebuiltAssets.map((asset) => ({ + rule_id: asset['security-rule'].rule_id, + name: TARGET_NAME, + tags: TARGET_TAGS, + })); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: rulesToUpgrade, + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(basePrebuiltAssets.length); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const expected = expectedResults.find((r) => r.rule_id === updatedRule.rule_id); + expect(updatedRule.name).toEqual(expected?.name); + expect(updatedRule.tags).toEqual(expected?.tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + expectedResults.forEach((expected) => { + const installedRule = installedRulesMap.get(expected.rule_id); + expect(installedRule?.name).toEqual(expected.name); + expect(installedRule?.tags).toEqual(expected.tags); + }); + }); + + it('upgrades specific rules to their MERGED versions', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const reviewResponse = await reviewPrebuiltRulesToUpgrade(supertest); + const expectedResults = reviewResponse.rules.map((upgradeInfo) => ({ + rule_id: upgradeInfo.rule_id, + name: upgradeInfo.diff.fields.name?.merged_version, + tags: upgradeInfo.diff.fields.tags?.merged_version, + })); + + const rulesToUpgrade = basePrebuiltAssets.map((rule) => ({ + rule_id: rule['security-rule'].rule_id, + revision: 0, + version: rule['security-rule'].version + 1, + })); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'MERGED', + rules: rulesToUpgrade, + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(basePrebuiltAssets.length); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const expected = expectedResults.find((r) => r.rule_id === updatedRule.rule_id); + expect(updatedRule.name).toEqual(expected?.name); + expect(updatedRule.tags).toEqual(expected?.tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + expectedResults.forEach((expected) => { + const installedRule = installedRulesMap.get(expected.rule_id); + expect(installedRule?.name).toEqual(expected.name); + expect(installedRule?.tags).toEqual(expected.tags); + }); + }); + + it('upgrades specific rules to their TARGET versions but overrides some fields with `fields` in the request payload', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const rulesToUpgrade = basePrebuiltAssets.map((rule) => ({ + rule_id: rule['security-rule'].rule_id, + revision: 0, + version: rule['security-rule'].version + 1, + fields: { + name: { pick_version: 'BASE' as PickVersionValues }, + }, + })); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: rulesToUpgrade, + }); + + const expectedResults = basePrebuiltAssets.map((asset) => ({ + rule_id: asset['security-rule'].rule_id, + name: asset['security-rule'].name, + tags: TARGET_TAGS, + })); + + expect(performUpgradeResponse.summary.succeeded).toEqual(basePrebuiltAssets.length); + + performUpgradeResponse.results.updated.forEach((updatedRule) => { + const expected = expectedResults.find((r) => r.rule_id === updatedRule.rule_id); + expect(updatedRule.name).toEqual(expected?.name); + expect(updatedRule.tags).toEqual(expected?.tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + expectedResults.forEach((expected) => { + const installedRule = installedRulesMap.get(expected.rule_id); + expect(installedRule?.name).toEqual(expected.name); + expect(installedRule?.tags).toEqual(expected.tags); + }); + }); + + it('upgrades specific rules with different pick_version at global, rule, and field levels', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + for (const baseRule of basePrebuiltAssets) { + await patchRule(supertest, log, { + rule_id: baseRule['security-rule'].rule_id, + name: CURRENT_NAME, + tags: CURRENT_TAGS, + }); + } + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, targetPrebuiltAssets); + + const rulesToUpgrade = [ + { + rule_id: basePrebuiltAssets[0]['security-rule'].rule_id, + revision: 1, + version: basePrebuiltAssets[0]['security-rule'].version + 1, + pick_version: 'CURRENT' as PickVersionValues, + }, + { + rule_id: basePrebuiltAssets[1]['security-rule'].rule_id, + revision: 1, + version: basePrebuiltAssets[1]['security-rule'].version + 1, + fields: { + name: { pick_version: 'TARGET' as PickVersionValues }, + tags: { pick_version: 'BASE' as PickVersionValues }, + }, + }, + { + rule_id: basePrebuiltAssets[2]['security-rule'].rule_id, + revision: 1, + version: basePrebuiltAssets[2]['security-rule'].version + 1, + }, + ]; + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'BASE', + rules: rulesToUpgrade, + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(3); + const updatedRulesMap = createIdToRuleMap(performUpgradeResponse.results.updated); + + const expectedResults = [ + { name: CURRENT_NAME, tags: CURRENT_TAGS }, + { name: TARGET_NAME, tags: basePrebuiltAssets[1]['security-rule'].tags }, + { + name: basePrebuiltAssets[2]['security-rule'].name, + tags: basePrebuiltAssets[2]['security-rule'].tags, + }, + ]; + + basePrebuiltAssets.forEach((asset, index) => { + const ruleId = asset['security-rule'].rule_id; + const updatedRule = updatedRulesMap.get(ruleId); + expect(updatedRule?.name).toEqual(expectedResults[index].name); + expect(updatedRule?.tags).toEqual(expectedResults[index].tags); + }); + + const installedRules = await getInstalledRules(supertest); + const installedRulesMap = createIdToRuleMap(installedRules.data); + + basePrebuiltAssets.forEach((asset, index) => { + const ruleId = asset['security-rule'].rule_id; + const installedRule = installedRulesMap.get(ruleId); + expect(installedRule?.name).toEqual(expectedResults[index].name); + expect(installedRule?.tags).toEqual(expectedResults[index].tags); + }); + }); + + it('successfully resolves a non-resolvable conflict by using pick_version:RESOLVED for that field', async () => { + const baseEqlRule = createRuleAssetSavedObjectOfType<EqlRuleCreateFields>('eql'); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [baseEqlRule]); + await installPrebuiltRules(es, supertest); + + // Patch the installed rule to edit its query + const patchedQuery = 'sequence by process.name [MY CURRENT QUERY]'; + await patchRule(supertest, log, { + rule_id: baseEqlRule['security-rule'].rule_id, + query: patchedQuery, + }); + + // Create a new version of the prebuilt rule asset with a different query and generate the conflict + const targetEqlRule = cloneDeep(baseEqlRule); + targetEqlRule['security-rule'].version += 1; + targetEqlRule['security-rule'].query = 'sequence by process.name [MY TARGET QUERY]'; + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetEqlRule]); + + const resolvedValue = { + query: 'sequence by process.name [MY RESOLVED QUERY]', + language: 'eql', + filters: [], + }; + + // Perform the upgrade with manual conflict resolution + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'MERGED', + rules: [ + { + rule_id: baseEqlRule['security-rule'].rule_id, + revision: 1, + version: baseEqlRule['security-rule'].version + 1, + fields: { + eql_query: { + pick_version: 'RESOLVED', + resolved_value: resolvedValue as RuleEqlQuery, + }, + }, + }, + ], + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); + const updatedRule = performUpgradeResponse.results.updated[0] as EqlRule; + expect(updatedRule.rule_id).toEqual(baseEqlRule['security-rule'].rule_id); + expect(updatedRule.query).toEqual(resolvedValue.query); + expect(updatedRule.filters).toEqual(resolvedValue.filters); + expect(updatedRule.language).toEqual(resolvedValue.language); + + const installedRules = await getInstalledRules(supertest); + const installedRule = installedRules.data.find( + (rule) => rule.rule_id === baseEqlRule['security-rule'].rule_id + ) as EqlRule; + expect(installedRule?.query).toEqual(resolvedValue.query); + expect(installedRule?.filters).toEqual(resolvedValue.filters); + expect(installedRule?.language).toEqual(resolvedValue.language); + }); + }); + + describe('edge cases and unhappy paths', () => { + const queryRule = createRuleAssetSavedObject({ + type: 'query', + language: 'kuery', + rule_id: 'query-rule', + }); + const eqlRule = createRuleAssetSavedObject({ + type: 'eql', + language: 'eql', + rule_id: 'eql-rule', + }); + + const basePrebuiltAssets = [queryRule, eqlRule]; + + it('rejects updates when rule type changes and pick_version is not TARGET at all levels', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + const targetMLRule = createRuleAssetSavedObject({ + rule_id: queryRule['security-rule'].rule_id, + version: queryRule['security-rule'].version + 1, + type: 'machine_learning', + machine_learning_job_id: 'job_id', + anomaly_threshold: 1, + }); + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetMLRule]); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'BASE', + rules: [ + { + rule_id: queryRule['security-rule'].rule_id, + revision: 0, + version: queryRule['security-rule'].version + 1, + }, + ], + }); + + expect(performUpgradeResponse.summary.failed).toEqual(1); + expect(performUpgradeResponse.errors[0].message).toContain( + 'Rule update for rule query-rule has a rule type change' + ); + }); + + it('rejects updates when incompatible fields are provided for a rule type', async () => { + const baseEqlRule = createRuleAssetSavedObjectOfType<EqlRuleCreateFields>('eql'); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [baseEqlRule]); + await installPrebuiltRules(es, supertest); + + // Create a new version of the prebuilt rule asset with a different query and generate the conflict + const targetEqlRule = cloneDeep(baseEqlRule); + targetEqlRule['security-rule'].version += 1; + targetEqlRule['security-rule'].query = 'sequence by process.name [MY TARGET QUERY]'; + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetEqlRule]); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: [ + { + rule_id: baseEqlRule['security-rule'].rule_id, + revision: 0, + version: baseEqlRule['security-rule'].version + 1, + fields: { + machine_learning_job_id: { pick_version: 'TARGET' }, + }, + }, + ], + }); + + expect(performUpgradeResponse.summary.failed).toEqual(1); + expect(performUpgradeResponse.errors[0].message).toContain( + "machine_learning_job_id is not a valid upgradeable field for type 'eql'" + ); + }); + + it('rejects updates with NON_SOLVABLE conflicts when using MERGED pick_version', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + await patchRule(supertest, log, { + rule_id: queryRule['security-rule'].rule_id, + name: CURRENT_NAME, + }); + + const targetQueryRule = cloneDeep(queryRule); + targetQueryRule['security-rule'].version += 1; + targetQueryRule['security-rule'].name = TARGET_NAME; + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetQueryRule]); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'MERGED', + rules: [ + { + rule_id: queryRule['security-rule'].rule_id, + revision: 1, + version: queryRule['security-rule'].version + 1, + }, + ], + }); + + expect(performUpgradeResponse.summary.failed).toEqual(1); + expect(performUpgradeResponse.errors[0].message).toContain( + `Automatic merge calculation for field 'name' in rule of rule_id ${performUpgradeResponse.errors[0].rules[0].rule_id} resulted in a conflict. Please resolve the conflict manually or choose another value for 'pick_version'` + ); + }); + + it('allows updates with NON_SOLVABLE conflicts when specific fields have non-MERGED pick_version', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + await patchRule(supertest, log, { + rule_id: queryRule['security-rule'].rule_id, + name: CURRENT_NAME, + }); + + const targetQueryRule = cloneDeep(queryRule); + targetQueryRule['security-rule'].version += 1; + targetQueryRule['security-rule'].name = TARGET_NAME; + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetQueryRule]); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'MERGED', + rules: [ + { + rule_id: queryRule['security-rule'].rule_id, + revision: 1, + version: queryRule['security-rule'].version + 1, + fields: { + name: { pick_version: 'TARGET' }, + }, + }, + ], + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); + expect(performUpgradeResponse.results.updated[0].name).toEqual(TARGET_NAME); + + const installedRules = await getInstalledRules(supertest); + const installedRule = installedRules.data.find( + (rule) => rule.rule_id === queryRule['security-rule'].rule_id + ); + expect(installedRule?.name).toEqual(TARGET_NAME); + }); + + it('rejects updates for specific fields with MERGED pick_version and NON_SOLVABLE conflicts', async () => { + await createHistoricalPrebuiltRuleAssetSavedObjects(es, basePrebuiltAssets); + await installPrebuiltRules(es, supertest); + + await patchRule(supertest, log, { + rule_id: queryRule['security-rule'].rule_id, + name: CURRENT_NAME, + }); + + const targetQueryRule = cloneDeep(queryRule); + targetQueryRule['security-rule'].version += 1; + targetQueryRule['security-rule'].name = TARGET_NAME; + + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetQueryRule]); + + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: [ + { + rule_id: queryRule['security-rule'].rule_id, + revision: 1, + version: queryRule['security-rule'].version + 1, + fields: { + name: { pick_version: 'MERGED' }, + }, + }, + ], + }); + + expect(performUpgradeResponse.summary.failed).toEqual(1); + expect(performUpgradeResponse.errors[0].message).toContain( + `Automatic merge calculation for field 'name' in rule of rule_id ${performUpgradeResponse.errors[0].rules[0].rule_id} resulted in a conflict. Please resolve the conflict manually or choose another value for 'pick_version'.` + ); + }); + + it('preserves FIELDS_TO_UPGRADE_TO_CURRENT_VERSION when upgrading to TARGET version with undefined fields', async () => { + const baseRule = + createRuleAssetSavedObjectOfType<ThreatMatchRuleCreateFields>('threat_match'); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [baseRule]); + await installPrebuiltRules(es, supertest); + + const ruleId = baseRule['security-rule'].rule_id; + + const installedBaseRule = ( + await securitySolutionApi.readRule({ + query: { + rule_id: ruleId, + }, + }) + ).body as ThreatMatchRule; + + // Patch the installed rule to set all FIELDS_TO_UPGRADE_TO_CURRENT_VERSION to some defined value + const currentValues: { [key: string]: unknown } = { + enabled: true, + exceptions_list: [ + { + id: 'test-list', + list_id: 'test-list', + type: 'detection', + namespace_type: 'single', + } as const, + ], + alert_suppression: { + group_by: ['host.name'], + duration: { value: 5, unit: 'm' as const }, + }, + actions: [await createAction(supertest)], + response_actions: [ + { + params: { + command: 'isolate' as const, + comment: 'comment', + }, + action_type_id: '.endpoint' as const, + }, + ], + meta: { some_key: 'some_value' }, + output_index: '.siem-signals-default', + namespace: 'default', + concurrent_searches: 5, + items_per_search: 100, + }; + + await securitySolutionApi.updateRule({ + body: { + ...installedBaseRule, + ...currentValues, + id: undefined, + }, + }); + + // Create a target version with undefined values for these fields + const targetRule = cloneDeep(baseRule); + targetRule['security-rule'].version += 1; + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + // @ts-expect-error + targetRule['security-rule'][field] = undefined; + }); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetRule]); + + // Perform the upgrade + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: [ + { + rule_id: baseRule['security-rule'].rule_id, + revision: 1, + version: baseRule['security-rule'].version + 1, + }, + ], + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); + const upgradedRule = performUpgradeResponse.results.updated[0] as ThreatMatchRule; + + // Check that all FIELDS_TO_UPGRADE_TO_CURRENT_VERSION still have their "current" values + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(upgradedRule[field]).toEqual(currentValues[field]); + }); + + // Verify the installed rule + const installedRules = await getInstalledRules(supertest); + const installedRule = installedRules.data.find( + (rule) => rule.rule_id === baseRule['security-rule'].rule_id + ) as ThreatMatchRule; + + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(installedRule[field]).toEqual(currentValues[field]); + }); + }); + + it('preserves FIELDS_TO_UPGRADE_TO_CURRENT_VERSION when fields are attempted to be updated via resolved values', async () => { + const baseRule = + createRuleAssetSavedObjectOfType<ThreatMatchRuleCreateFields>('threat_match'); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [baseRule]); + await installPrebuiltRules(es, supertest); + + const ruleId = baseRule['security-rule'].rule_id; + + const installedBaseRule = ( + await securitySolutionApi.readRule({ + query: { + rule_id: ruleId, + }, + }) + ).body as ThreatMatchRule; + + // Set current values for FIELDS_TO_UPGRADE_TO_CURRENT_VERSION + const currentValues: { [key: string]: unknown } = { + enabled: true, + exceptions_list: [ + { + id: 'test-list', + list_id: 'test-list', + type: 'detection', + namespace_type: 'single', + } as const, + ], + alert_suppression: { + group_by: ['host.name'], + duration: { value: 5, unit: 'm' as const }, + }, + actions: [await createAction(supertest)], + response_actions: [ + { + params: { + command: 'isolate' as const, + comment: 'comment', + }, + action_type_id: '.endpoint' as const, + }, + ], + meta: { some_key: 'some_value' }, + output_index: '.siem-signals-default', + namespace: 'default', + concurrent_searches: 5, + items_per_search: 100, + }; + + await securitySolutionApi.updateRule({ + body: { + ...installedBaseRule, + ...currentValues, + id: undefined, + }, + }); + + // Create a target version with undefined values for these fields + const targetRule = cloneDeep(baseRule); + targetRule['security-rule'].version += 1; + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + // @ts-expect-error + targetRule['security-rule'][field] = undefined; + }); + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [targetRule]); + + // Create resolved values different from current values + const resolvedValues: { [key: string]: unknown } = { + exceptions_list: [], + alert_suppression: { + group_by: ['test'], + duration: { value: 10, unit: 'm' as const }, + }, + }; + + const fields = Object.fromEntries( + Object.keys(resolvedValues).map((field) => [ + field, + { + pick_version: 'RESOLVED' as PickVersionValues, + resolved_value: resolvedValues[field], + }, + ]) + ); + + // Perform the upgrade with resolved values + const performUpgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: ModeEnum.SPECIFIC_RULES, + pick_version: 'TARGET', + rules: [ + { + rule_id: baseRule['security-rule'].rule_id, + revision: 1, + version: baseRule['security-rule'].version + 1, + fields, + }, + ], + }); + + expect(performUpgradeResponse.summary.succeeded).toEqual(1); + const upgradedRule = performUpgradeResponse.results.updated[0] as ThreatMatchRule; + + // Check that all FIELDS_TO_UPGRADE_TO_CURRENT_VERSION still have their "current" values + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(upgradedRule[field]).toEqual(currentValues[field]); + }); + + // Verify the installed rule + const installedRules = await getInstalledRules(supertest); + const installedRule = installedRules.data.find( + (rule) => rule.rule_id === baseRule['security-rule'].rule_id + ) as ThreatMatchRule; + + FIELDS_TO_UPGRADE_TO_CURRENT_VERSION.forEach((field) => { + expect(installedRule[field]).toEqual(currentValues[field]); + }); + }); + }); + }); +}; + +function createIdToRuleMap(rules: Array<PrebuiltRuleAsset | RuleResponse>) { + return new Map(rules.map((rule) => [rule.rule_id, rule])); +} + +async function createAction(supertest: SuperTest.Agent) { + const createConnector = async (payload: Record<string, unknown>) => + (await supertest.post('/api/actions/action').set('kbn-xsrf', 'true').send(payload).expect(200)) + .body; + + const createWebHookConnector = () => createConnector(getWebHookAction()); + + const webHookAction = await createWebHookConnector(); + + const defaultRuleAction = { + id: webHookAction.id, + action_type_id: '.webhook' as const, + group: 'default' as const, + params: { + body: '{"test":"a default action"}', + }, + frequency: { + notifyWhen: 'onThrottleInterval' as const, + summary: true, + throttle: '1h' as const, + }, + uuid: 'd487ec3d-05f2-44ad-8a68-11c97dc92202', + }; + + return defaultRuleAction; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts index cd336f91fae13..a23ddf40979f6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules.ts @@ -16,7 +16,7 @@ import { getPrebuiltRulesAndTimelinesStatus, getPrebuiltRulesStatus, installPrebuiltRules, - upgradePrebuiltRules, + performUpgradePrebuiltRules, fetchRule, patchRule, } from '../../../../utils'; @@ -100,7 +100,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); // Call the install prebuilt rules again and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); + const response = await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(response.summary.succeeded).toBe(1); expect(response.summary.skipped).toBe(0); }); @@ -121,7 +124,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(installResponse.summary.skipped).toBe(0); // Call the upgrade prebuilt rules endpoint and check that no rules were updated - const upgradeResponse = await upgradePrebuiltRules(es, supertest); + const upgradeResponse = await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(upgradeResponse.summary.succeeded).toBe(0); expect(upgradeResponse.summary.skipped).toBe(0); }); @@ -178,7 +184,10 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Upgrade to a newer version with the same type - await upgradePrebuiltRules(es, supertest); + await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(await fetchRule(supertest, { ruleId: 'rule-to-test-1' })).toMatchObject({ id: initialRuleSoId, @@ -186,8 +195,7 @@ export default ({ getService }: FtrProviderContext): void => { enabled: false, actions, exceptions_list: exceptionsList, - timeline_id: 'some-timeline-id', - timeline_title: 'Some timeline title', + // current values for timeline_id and timeline_title are lost when updating to TARGET version }); }); }); @@ -250,7 +258,10 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Upgrade to a newer version with a different rule type - await upgradePrebuiltRules(es, supertest); + await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(await fetchRule(supertest, { ruleId: 'rule-to-test-2' })).toMatchObject({ id: initialRuleSoId, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts index 049ae3a5a6fd8..0eb37b1112f27 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/management/trial_license_complete_tier/upgrade_prebuilt_rules_with_historical_versions.ts @@ -15,7 +15,7 @@ import { createHistoricalPrebuiltRuleAssetSavedObjects, getPrebuiltRulesStatus, installPrebuiltRules, - upgradePrebuiltRules, + performUpgradePrebuiltRules, } from '../../../../utils'; import { deleteAllRules } from '../../../../../../../common/utils/security_solution'; @@ -110,7 +110,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); + const response = await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(response.summary.succeeded).toBe(1); expect(response.summary.total).toBe(1); @@ -138,7 +141,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated - const response = await upgradePrebuiltRules(es, supertest); + const response = await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(response.summary.succeeded).toBe(1); expect(response.summary.total).toBe(1); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/update_prebuilt_rules_package/trial_license_complete_tier/update_prebuilt_rules_package.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/update_prebuilt_rules_package/trial_license_complete_tier/update_prebuilt_rules_package.ts index 8e26b089a9f80..b551d793406ce 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/update_prebuilt_rules_package/trial_license_complete_tier/update_prebuilt_rules_package.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/prebuilt_rules/update_prebuilt_rules_package/trial_license_complete_tier/update_prebuilt_rules_package.ts @@ -19,7 +19,7 @@ import { getPrebuiltRulesStatus, installPrebuiltRules, installPrebuiltRulesPackageByVersion, - upgradePrebuiltRules, + performUpgradePrebuiltRules, reviewPrebuiltRulesToInstall, reviewPrebuiltRulesToUpgrade, } from '../../../../utils'; @@ -227,12 +227,13 @@ export default ({ getService }: FtrProviderContext): void => { prebuiltRulesToUpgradeReviewAfterLatestPackageInstallation.stats.num_rules_to_upgrade_total ).toBe(statusAfterLatestPackageInstallation.stats.num_prebuilt_rules_to_upgrade); - // Call the upgrade _perform endpoint and verify that the number of upgraded rules is the same as the one - // returned by the _review endpoint and the status endpoint - const upgradePrebuiltRulesResponseAfterLatestPackageInstallation = await upgradePrebuiltRules( - es, - supertest - ); + // Call the upgrade _perform endpoint to upgrade all rules to their target version and verify that the number + // of upgraded rules is the same as the one returned by the _review endpoint and the status endpoint + const upgradePrebuiltRulesResponseAfterLatestPackageInstallation = + await performUpgradePrebuiltRules(es, supertest, { + mode: 'ALL_RULES', + pick_version: 'TARGET', + }); expect(upgradePrebuiltRulesResponseAfterLatestPackageInstallation.summary.succeeded).toEqual( statusAfterLatestPackageInstallation.stats.num_prebuilt_rules_to_upgrade diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts index df35d2c439757..8ecb591272492 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/trial_license_complete_tier/export_rules.ts @@ -411,6 +411,7 @@ function expectToMatchRuleSchema(obj: RuleResponse): void { severity: expect.any(String), output_index: expect.any(String), author: expect.arrayContaining([]), + license: expect.any(String), false_positives: expect.arrayContaining([]), from: expect.any(String), max_signals: expect.any(Number), diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts index a5c5fe00ed700..a27f99b6f75e8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts @@ -30,6 +30,7 @@ export function getCustomQueryRuleParams( interval: '100m', from: 'now-6m', author: [], + license: 'Elastic License v2', enabled: false, ...rewrites, }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts index 20a8e6cf17280..3ebd928123cc4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/create_prebuilt_rule_saved_objects.ts @@ -9,11 +9,21 @@ import { Client } from '@elastic/elasticsearch'; import { PrebuiltRuleAsset } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules'; import { getPrebuiltRuleMock, + getPrebuiltRuleMockOfType, getPrebuiltRuleWithExceptionsMock, } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules/mocks'; +import type { TypeSpecificCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { ELASTIC_SECURITY_RULE_ID } from '@kbn/security-solution-plugin/common'; import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +const ruleAssetSavedObjectESFields = { + type: 'security-rule', + references: [], + coreMigrationVersion: '8.6.0', + updated_at: '2022-11-01T12:56:39.717Z', + created_at: '2022-11-01T12:56:39.717Z', +}; + /** * A helper function to create a rule asset saved object * @@ -22,11 +32,20 @@ import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-se */ export const createRuleAssetSavedObject = (overrideParams: Partial<PrebuiltRuleAsset>) => ({ 'security-rule': getPrebuiltRuleMock(overrideParams), - type: 'security-rule', - references: [], - coreMigrationVersion: '8.6.0', - updated_at: '2022-11-01T12:56:39.717Z', - created_at: '2022-11-01T12:56:39.717Z', + ...ruleAssetSavedObjectESFields, +}); + +/** + * A helper function to create a rule asset saved object + * + * @param overrideParams Params to override the default mock + * @returns Created rule asset saved object + */ +export const createRuleAssetSavedObjectOfType = <T extends TypeSpecificCreateProps>( + type: T['type'] +) => ({ + 'security-rule': getPrebuiltRuleMockOfType<T>(type), + ...ruleAssetSavedObjectESFields, }); export const SAMPLE_PREBUILT_RULES = [ diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/index.ts index fbf9ab7b36384..fabd3df2f2d16 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/index.ts @@ -19,4 +19,4 @@ export * from './install_prebuilt_rules_fleet_package'; export * from './install_prebuilt_rules'; export * from './review_install_prebuilt_rules'; export * from './review_upgrade_prebuilt_rules'; -export * from './upgrade_prebuilt_rules'; +export * from './perform_upgrade_prebuilt_rules'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/perform_upgrade_prebuilt_rules.ts similarity index 67% rename from x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts rename to x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/perform_upgrade_prebuilt_rules.ts index f12d0adbc65f3..c9b2543d61d69 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/perform_upgrade_prebuilt_rules.ts @@ -7,8 +7,8 @@ import { PERFORM_RULE_UPGRADE_URL, - RuleVersionSpecifier, PerformRuleUpgradeResponseBody, + PerformRuleUpgradeRequestBody, } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type { Client } from '@elastic/elasticsearch'; import type SuperTest from 'supertest'; @@ -17,30 +17,21 @@ import { refreshSavedObjectIndices } from '../../refresh_index'; /** * Upgrades available prebuilt rules in Kibana. * - * - Pass in an array of rule version specifiers to upgrade specific rules. Otherwise - * all available rules will be upgraded. - * * @param supertest SuperTest instance - * @param rules Array of rule version specifiers to upgrade (optional) + * @param pazload Array of rule version specifiers to upgrade (optional) * @returns Upgrade prebuilt rules response */ -export const upgradePrebuiltRules = async ( +export const performUpgradePrebuiltRules = async ( es: Client, supertest: SuperTest.Agent, - rules?: RuleVersionSpecifier[] + requestBody: PerformRuleUpgradeRequestBody ): Promise<PerformRuleUpgradeResponseBody> => { - let payload = {}; - if (rules) { - payload = { mode: 'SPECIFIC_RULES', rules, pick_version: 'TARGET' }; - } else { - payload = { mode: 'ALL_RULES', pick_version: 'TARGET' }; - } const response = await supertest .post(PERFORM_RULE_UPGRADE_URL) .set('kbn-xsrf', 'true') .set('elastic-api-version', '1') .set('x-elastic-internal-origin', 'foo') - .send(payload) + .send(requestBody) .expect(200); await refreshSavedObjectIndices(es); From d85b51db222f29efbd2d8f32067a13b4932feba8 Mon Sep 17 00:00:00 2001 From: Philippe Oberti <philippe.oberti@elastic.co> Date: Wed, 16 Oct 2024 04:42:23 +0200 Subject: [PATCH 30/31] [Security Solution][Notes] - allow filtering by user (#195519) --- .../output/kibana.serverless.staging.yaml | 5 ++ oas_docs/output/kibana.serverless.yaml | 5 ++ oas_docs/output/kibana.staging.yaml | 5 ++ oas_docs/output/kibana.yaml | 5 ++ .../timeline/get_notes/get_notes_route.gen.ts | 1 + .../get_notes/get_notes_route.schema.yaml | 5 ++ ...imeline_api_2023_10_31.bundled.schema.yaml | 5 ++ ...imeline_api_2023_10_31.bundled.schema.yaml | 5 ++ .../public/common/mock/global_state.ts | 1 + .../security_solution/public/notes/api/api.ts | 3 + .../public/notes/components/add_note.tsx | 2 +- .../notes/components/search_row.test.tsx | 65 ++++++++++++++++ .../public/notes/components/search_row.tsx | 73 +++++++++++------- .../public/notes/components/test_ids.ts | 2 + .../public/notes/components/utility_bar.tsx | 13 +++- .../notes/pages/note_management_page.tsx | 14 +++- .../public/notes/store/notes.slice.test.ts | 24 +++++- .../public/notes/store/notes.slice.ts | 74 ++++++++++++------- .../lib/timeline/routes/notes/get_notes.ts | 17 +++++ .../trial_license_complete_tier/helpers.ts | 14 ++-- .../trial_license_complete_tier/notes.ts | 35 ++++++++- 21 files changed, 309 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 6df65e8ae2e3e..d7b1b6d02323a 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -35261,6 +35261,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 6df65e8ae2e3e..d7b1b6d02323a 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -35261,6 +35261,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 76e217fcba16d..24b0462ae93ef 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -38692,6 +38692,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 76e217fcba16d..24b0462ae93ef 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -38692,6 +38692,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts index c4c48022f6512..a4659d8d98d5a 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts @@ -40,6 +40,7 @@ export const GetNotesRequestQuery = z.object({ sortField: z.string().nullable().optional(), sortOrder: z.string().nullable().optional(), filter: z.string().nullable().optional(), + userFilter: z.string().nullable().optional(), }); export type GetNotesRequestQueryInput = z.input<typeof GetNotesRequestQuery>; diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml index 985e7728b7cc8..cc8681c6f8f64 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml @@ -51,6 +51,11 @@ paths: schema: type: string nullable: true + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': description: Indicates the requested notes were returned. diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 48eb959168856..8de192ce26826 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -97,6 +97,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 343ec3dc30a73..66127d5b8cd52 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -97,6 +97,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 16e1e7edf0eaa..01eec48ed7718 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -549,6 +549,7 @@ export const mockGlobalState: State = { direction: 'desc' as const, }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 4bda803950b84..3bac1a0a2d7df 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -42,6 +42,7 @@ export const fetchNotes = async ({ sortField, sortOrder, filter, + userFilter, search, }: { page: number; @@ -49,6 +50,7 @@ export const fetchNotes = async ({ sortField: string; sortOrder: string; filter: string; + userFilter: string; search: string; }) => { const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, { @@ -58,6 +60,7 @@ export const fetchNotes = async ({ sortField, sortOrder, filter, + userFilter, search, }, version: '2023-10-31', diff --git a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx index b3b226550b66f..78a84064467f6 100644 --- a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx @@ -88,7 +88,7 @@ export const AddNote = memo( createNote({ note: { timelineId: timelineId || '', - eventId, + eventId: eventId || '', note: editorValue, }, }) diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx new file mode 100644 index 0000000000000..71693edb81724 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { SearchRow } from './search_row'; +import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; +import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; + +jest.mock('../../common/components/user_profiles/use_suggest_users'); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +describe('SearchRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }], + }); + }); + + it('should render the component', () => { + const { getByTestId } = render(<SearchRow />); + + expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the correct action when entering a value in the search bar', async () => { + const { getByTestId } = render(<SearchRow />); + + const searchBox = getByTestId(SEARCH_BAR_TEST_ID); + + await userEvent.type(searchBox, 'test'); + await userEvent.keyboard('{enter}'); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it('should call the correct action when select a user', async () => { + const { getByTestId } = render(<SearchRow />); + + const userSelect = getByTestId('comboBoxSearchInput'); + userSelect.focus(); + + const option = await screen.findByText('test'); + fireEvent.click(option); + + expect(mockDispatch).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx index 6e08251a61135..9a33c84cbec58 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -5,25 +5,19 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; -import React, { useMemo, useCallback } from 'react'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; +import React, { useMemo, useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; -import { userSearchedNotes } from '..'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { i18n } from '@kbn/i18n'; +import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; +import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; +import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; +import { userFilterUsers, userSearchedNotes } from '..'; -const SearchRowContainer = styled.div` - &:not(:last-child) { - margin-bottom: ${(props) => props.theme.eui.euiSizeL}; - } -`; - -SearchRowContainer.displayName = 'SearchRowContainer'; - -const SearchRowFlexGroup = styled(EuiFlexGroup)` - margin-bottom: ${(props) => props.theme.eui.euiSizeXS}; -`; - -SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; +export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', { + defaultMessage: 'Users', +}); export const SearchRow = React.memo(() => { const dispatch = useDispatch(); @@ -31,7 +25,7 @@ export const SearchRow = React.memo(() => { () => ({ placeholder: 'Search note contents', incremental: false, - 'data-test-subj': 'notes-search-bar', + 'data-test-subj': SEARCH_BAR_TEST_ID, }), [] ); @@ -43,14 +37,43 @@ export const SearchRow = React.memo(() => { [dispatch] ); + const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({ + searchTerm: '', + }); + const users = useMemo( + () => + (userProfiles || []).map((userProfile: UserProfileWithAvatar) => ({ + label: userProfile.user.full_name || userProfile.user.username, + })), + [userProfiles] + ); + + const [selectedUser, setSelectedUser] = useState<Array<EuiComboBoxOptionOption<string>>>(); + const onChange = useCallback( + (user: Array<EuiComboBoxOptionOption<string>>) => { + setSelectedUser(user); + dispatch(userFilterUsers(user.length > 0 ? user[0].label : '')); + }, + [dispatch] + ); + return ( - <SearchRowContainer> - <SearchRowFlexGroup gutterSize="s"> - <EuiFlexItem> - <EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" /> - </EuiFlexItem> - </SearchRowFlexGroup> - </SearchRowContainer> + <EuiFlexGroup gutterSize="m"> + <EuiFlexItem> + <EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiComboBox + prepend={USERS_DROPDOWN} + singleSelection={{ asPlainText: true }} + options={users} + selectedOptions={selectedUser} + onChange={onChange} + isLoading={isLoadingSuggestedUsers} + data-test-subj={USER_SELECT_TEST_ID} + /> + </EuiFlexItem> + </EuiFlexGroup> ); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts index ac4eeb1948748..1464ed17d8764 100644 --- a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -19,3 +19,5 @@ export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const; export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const; export const NOTE_CONTENT_BUTTON_TEST_ID = `${PREFIX}NoteContentButton` as const; export const NOTE_CONTENT_POPOVER_TEST_ID = `${PREFIX}NoteContentPopover` as const; +export const SEARCH_BAR_TEST_ID = `${PREFIX}SearchBar` as const; +export const USER_SELECT_TEST_ID = `${PREFIX}UserSelect` as const; diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index f0a337cb6c217..e34824d1ad814 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -23,6 +23,7 @@ import { selectNotesTableSelectedIds, selectNotesTableSearch, userSelectedBulkDelete, + selectNotesTableUserFilters, } from '..'; export const BATCH_ACTIONS = i18n.translate( @@ -51,6 +52,7 @@ export const NotesUtilityBar = React.memo(() => { const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const selectedItems = useSelector(selectNotesTableSelectedIds); + const notesUserFilters = useSelector(selectNotesTableUserFilters); const resultsCount = useMemo(() => { const { perPage, page, total } = pagination; const startOfCurrentPage = perPage * (page - 1) + 1; @@ -83,10 +85,19 @@ export const NotesUtilityBar = React.memo(() => { sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter: notesUserFilters, search: notesSearch, }) ); - }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + }, [ + dispatch, + pagination.page, + pagination.perPage, + sort.field, + sort.direction, + notesUserFilters, + notesSearch, + ]); return ( <UtilityBar border> <UtilityBarSection> diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 2b7f0f690532c..e329f0d75b911 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -36,6 +36,7 @@ import { selectNotesTablePendingDeleteIds, selectFetchNotesError, ReqStatus, + selectNotesTableUserFilters, } from '..'; import type { NotesState } from '..'; import { SearchRow } from '../components/search_row'; @@ -119,6 +120,7 @@ export const NoteManagementPage = () => { const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const notesSearch = useSelector(selectNotesTableSearch); + const notesUserFilters = useSelector(selectNotesTableUserFilters); const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); const isDeleteModalVisible = pendingDeleteIds.length > 0; const fetchNotesStatus = useSelector(selectFetchNotesStatus); @@ -134,10 +136,19 @@ export const NoteManagementPage = () => { sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter: notesUserFilters, search: notesSearch, }) ); - }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + }, [ + dispatch, + pagination.page, + pagination.perPage, + sort.field, + sort.direction, + notesUserFilters, + notesSearch, + ]); useEffect(() => { fetchData(); @@ -212,6 +223,7 @@ export const NoteManagementPage = () => { <Title title={i18n.NOTES} /> <EuiSpacer size="m" /> <SearchRow /> + <EuiSpacer size="m" /> <NotesUtilityBar /> <EuiBasicTable items={notes} diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 3ab0333dc1abb..7cbaecf7d7135 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -46,6 +46,8 @@ import { fetchNotesBySavedObjectIds, selectNotesBySavedObjectId, selectSortedNotesBySavedObjectId, + userFilterUsers, + selectNotesTableUserFilters, userClosedCreateErrorToast, } from './notes.slice'; import type { NotesState } from './notes.slice'; @@ -69,7 +71,7 @@ const generateNoteMock = (documentId: string): Note => ({ const mockNote1 = generateNoteMock('1'); const mockNote2 = generateNoteMock('2'); -const initialNonEmptyState = { +const initialNonEmptyState: NotesState = { entities: { [mockNote1.noteId]: mockNote1, [mockNote2.noteId]: mockNote2, @@ -99,6 +101,7 @@ const initialNonEmptyState = { direction: 'desc' as const, }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], @@ -501,6 +504,17 @@ describe('notesSlice', () => { }); }); + describe('userFilterUsers', () => { + it('should set correct value to filter users', () => { + const action = { type: userFilterUsers.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + userFilter: 'abc', + }); + }); + }); + describe('userSearchedNotes', () => { it('should set correct value to search notes', () => { const action = { type: userSearchedNotes.type, payload: 'abc' }; @@ -837,6 +851,14 @@ describe('notesSlice', () => { expect(selectNotesTableSearch(state)).toBe('test search'); }); + it('should select associated filter', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, userFilter: 'abc' }, + }; + expect(selectNotesTableUserFilters(state)).toBe('abc'); + }); + it('should select notes table pending delete ids', () => { const state = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 979e984b5719b..d5a4e7d4ab14e 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -57,6 +57,7 @@ export interface NotesState extends EntityState<Note> { direction: 'asc' | 'desc'; }; filter: string; + userFilter: string; search: string; selectedIds: string[]; pendingDeleteIds: string[]; @@ -91,6 +92,7 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ direction: 'desc', }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], @@ -124,12 +126,21 @@ export const fetchNotes = createAsyncThunk< sortField: string; sortOrder: string; filter: string; + userFilter: string; search: string; }, {} >('notes/fetchNotes', async (args) => { - const { page, perPage, sortField, sortOrder, filter, search } = args; - const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search }); + const { page, perPage, sortField, sortOrder, filter, userFilter, search } = args; + const res = await fetchNotesApi({ + page, + perPage, + sortField, + sortOrder, + filter, + userFilter, + search, + }); return { ...normalizeEntities('notes' in res ? res.notes : []), totalCount: 'totalCount' in res ? res.totalCount : 0, @@ -152,7 +163,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?: await deleteNotesApi(ids); if (refetch) { const state = getState() as State; - const { search, pagination, sort } = state.notes; + const { search, pagination, userFilter, sort } = state.notes; dispatch( fetchNotes({ page: pagination.page, @@ -160,6 +171,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?: sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter, search, }) ); @@ -172,99 +184,102 @@ const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, reducers: { - userSelectedPage: (state, action: { payload: number }) => { + userSelectedPage: (state: NotesState, action: { payload: number }) => { state.pagination.page = action.payload; }, - userSelectedPerPage: (state, action: { payload: number }) => { + userSelectedPerPage: (state: NotesState, action: { payload: number }) => { state.pagination.perPage = action.payload; }, userSortedNotes: ( - state, + state: NotesState, action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } } ) => { state.sort = action.payload; }, - userFilteredNotes: (state, action: { payload: string }) => { + userFilteredNotes: (state: NotesState, action: { payload: string }) => { state.filter = action.payload; }, - userSearchedNotes: (state, action: { payload: string }) => { + userFilterUsers: (state: NotesState, action: { payload: string }) => { + state.userFilter = action.payload; + }, + userSearchedNotes: (state: NotesState, action: { payload: string }) => { state.search = action.payload; }, - userSelectedRow: (state, action: { payload: string[] }) => { + userSelectedRow: (state: NotesState, action: { payload: string[] }) => { state.selectedIds = action.payload; }, - userClosedDeleteModal: (state) => { + userClosedDeleteModal: (state: NotesState) => { state.pendingDeleteIds = []; }, - userSelectedNotesForDeletion: (state, action: { payload: string }) => { + userSelectedNotesForDeletion: (state: NotesState, action: { payload: string }) => { state.pendingDeleteIds = [action.payload]; }, - userSelectedBulkDelete: (state) => { + userSelectedBulkDelete: (state: NotesState) => { state.pendingDeleteIds = state.selectedIds; }, - userClosedCreateErrorToast: (state) => { + userClosedCreateErrorToast: (state: NotesState) => { state.error.createNote = null; }, }, extraReducers(builder) { builder - .addCase(fetchNotesByDocumentIds.pending, (state) => { + .addCase(fetchNotesByDocumentIds.pending, (state: NotesState) => { state.status.fetchNotesByDocumentIds = ReqStatus.Loading; }) - .addCase(fetchNotesByDocumentIds.fulfilled, (state, action) => { + .addCase(fetchNotesByDocumentIds.fulfilled, (state: NotesState, action) => { notesAdapter.upsertMany(state, action.payload.entities.notes); state.status.fetchNotesByDocumentIds = ReqStatus.Succeeded; }) - .addCase(fetchNotesByDocumentIds.rejected, (state, action) => { + .addCase(fetchNotesByDocumentIds.rejected, (state: NotesState, action) => { state.status.fetchNotesByDocumentIds = ReqStatus.Failed; state.error.fetchNotesByDocumentIds = action.payload ?? action.error; }) - .addCase(fetchNotesBySavedObjectIds.pending, (state) => { + .addCase(fetchNotesBySavedObjectIds.pending, (state: NotesState) => { state.status.fetchNotesBySavedObjectIds = ReqStatus.Loading; }) - .addCase(fetchNotesBySavedObjectIds.fulfilled, (state, action) => { + .addCase(fetchNotesBySavedObjectIds.fulfilled, (state: NotesState, action) => { notesAdapter.upsertMany(state, action.payload.entities.notes); state.status.fetchNotesBySavedObjectIds = ReqStatus.Succeeded; }) - .addCase(fetchNotesBySavedObjectIds.rejected, (state, action) => { + .addCase(fetchNotesBySavedObjectIds.rejected, (state: NotesState, action) => { state.status.fetchNotesBySavedObjectIds = ReqStatus.Failed; state.error.fetchNotesBySavedObjectIds = action.payload ?? action.error; }) - .addCase(createNote.pending, (state) => { + .addCase(createNote.pending, (state: NotesState) => { state.status.createNote = ReqStatus.Loading; }) - .addCase(createNote.fulfilled, (state, action) => { + .addCase(createNote.fulfilled, (state: NotesState, action) => { notesAdapter.addMany(state, action.payload.entities.notes); state.status.createNote = ReqStatus.Succeeded; }) - .addCase(createNote.rejected, (state, action) => { + .addCase(createNote.rejected, (state: NotesState, action) => { state.status.createNote = ReqStatus.Failed; state.error.createNote = action.payload ?? action.error; }) - .addCase(deleteNotes.pending, (state) => { + .addCase(deleteNotes.pending, (state: NotesState) => { state.status.deleteNotes = ReqStatus.Loading; }) - .addCase(deleteNotes.fulfilled, (state, action) => { + .addCase(deleteNotes.fulfilled, (state: NotesState, action) => { notesAdapter.removeMany(state, action.payload); state.status.deleteNotes = ReqStatus.Succeeded; state.pendingDeleteIds = state.pendingDeleteIds.filter( (value) => !action.payload.includes(value) ); }) - .addCase(deleteNotes.rejected, (state, action) => { + .addCase(deleteNotes.rejected, (state: NotesState, action) => { state.status.deleteNotes = ReqStatus.Failed; state.error.deleteNotes = action.payload ?? action.error; }) - .addCase(fetchNotes.pending, (state) => { + .addCase(fetchNotes.pending, (state: NotesState) => { state.status.fetchNotes = ReqStatus.Loading; }) - .addCase(fetchNotes.fulfilled, (state, action) => { + .addCase(fetchNotes.fulfilled, (state: NotesState, action) => { notesAdapter.setAll(state, action.payload.entities.notes); state.pagination.total = action.payload.totalCount; state.status.fetchNotes = ReqStatus.Succeeded; state.selectedIds = []; }) - .addCase(fetchNotes.rejected, (state, action) => { + .addCase(fetchNotes.rejected, (state: NotesState, action) => { state.status.fetchNotes = ReqStatus.Failed; state.error.fetchNotes = action.payload ?? action.error; }); @@ -307,6 +322,8 @@ export const selectNotesTableSelectedIds = (state: State) => state.notes.selecte export const selectNotesTableSearch = (state: State) => state.notes.search; +export const selectNotesTableUserFilters = (state: State) => state.notes.userFilter; + export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes; @@ -394,6 +411,7 @@ export const { userSelectedPerPage, userSortedNotes, userFilteredNotes, + userFilterUsers, userSearchedNotes, userSelectedRow, userClosedDeleteModal, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 925379baedad5..bc6c83e2b159c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -11,6 +11,7 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey' import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server'; import { nodeBuilder } from '@kbn/es-query'; +import type { KueryNode } from '@kbn/es-query'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants'; @@ -126,6 +127,22 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { sortOrder, filter, }; + + // retrieve all the notes created by a specific user + const userFilter = queryParams?.userFilter; + if (userFilter) { + // we need to combine the associatedFilter with the filter query + // we have to type case here because the filter is a string (from the schema) and that cannot be changed as it would be a breaking change + const filterAsKueryNode: KueryNode = (filter || '') as unknown as KueryNode; + + options.filter = nodeBuilder.and([ + nodeBuilder.is(`${noteSavedObjectType}.attributes.createdBy`, userFilter), + filterAsKueryNode, + ]); + } else { + options.filter = filter; + } + const res = await getAllSavedNote(frameworkRequest, options); const body: GetNotesResponse = res ?? {}; return response.ok({ body }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts index a5944dc8c6149..5bf4d61c8b595 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts @@ -7,7 +7,10 @@ import type SuperTest from 'supertest'; import { v4 as uuidv4 } from 'uuid'; -import { BareNote, TimelineTypeEnum } from '@kbn/security-solution-plugin/common/api/timeline'; +import { + PersistNoteRouteRequestBody, + TimelineTypeEnum, +} from '@kbn/security-solution-plugin/common/api/timeline'; import { NOTE_URL } from '@kbn/security-solution-plugin/common/constants'; import type { Client } from '@elastic/elasticsearch'; import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; @@ -58,7 +61,6 @@ export const createNote = async ( note: { documentId?: string; savedObjectId?: string; - user?: string; text: string; } ) => @@ -70,9 +72,9 @@ export const createNote = async ( eventId: note.documentId || '', timelineId: note.savedObjectId || '', created: Date.now(), - createdBy: note.user || 'elastic', + createdBy: 'elastic', updated: Date.now(), - updatedBy: note.user || 'elastic', + updatedBy: 'elastic', note: note.text, - } as BareNote, - }); + }, + } as PersistNoteRouteRequestBody); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts index dabb453f80158..8a636358c2649 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts @@ -408,8 +408,41 @@ export default function ({ getService }: FtrProviderContext) { }); // TODO should add more tests for the filter query parameter (I don't know how it's supposed to work) - // TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values + + // TODO figure out why this test is failing on CI but not locally + // we can't really test for other users because the persistNote endpoint forces overrideOwner to be true then all the notes created here are owned by the elastic user + it.skip('should retrieve all notes that have been created by a specific user', async () => { + await Promise.all([ + createNote(supertest, { text: 'first note' }), + createNote(supertest, { text: 'second note' }), + ]); + + const response = await supertest + .get('/api/note?userFilter=elastic') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(2); + }); + + it('should return nothing if no notes have been created by that user', async () => { + await Promise.all([ + createNote(supertest, { text: 'first note' }), + createNote(supertest, { text: 'second note' }), + ]); + + const response = await supertest + .get('/api/note?userFilter=user1') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(0); + }); }); }); } From 983a3e5723f7c2ab6e33663e03355f431723b1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= <contact@patrykkopycinski.com> Date: Wed, 16 Oct 2024 05:41:57 +0200 Subject: [PATCH 31/31] Kb settings followup (#195733) --- .../data_views/data_views_api_client.test.ts | 12 + .../data_views/data_views_api_client.ts | 2 +- .../assistant/assistant_overlay/index.tsx | 12 +- .../flyout/index.tsx | 4 + .../inline_actions/index.tsx | 1 + .../impl/assistant/index.test.tsx | 106 ----- .../impl/assistant/index.tsx | 8 +- .../alerts_settings/alerts_settings.test.tsx | 4 +- .../alerts_settings/alerts_settings_modal.tsx | 63 +++ .../settings/assistant_settings.test.tsx | 23 +- .../assistant/settings/assistant_settings.tsx | 118 +---- .../assistant_settings_button.test.tsx | 7 - .../settings/assistant_settings_button.tsx | 46 +- .../assistant_settings_management.test.tsx | 52 +- .../assistant_settings_management.tsx | 29 +- .../impl/assistant/settings/const.ts | 14 +- .../settings_context_menu.tsx | 54 ++- .../impl/assistant_context/constants.tsx | 2 +- .../impl/assistant_context/index.tsx | 23 +- .../impl/assistant_context/types.tsx | 2 + .../connector_missing_callout/index.test.tsx | 2 + .../anonymization_settings/index.test.tsx | 1 + .../index.tsx | 75 ++- .../impl/knowledge_base/alerts_range.tsx | 1 + .../knowledge_base_settings.tsx | 15 +- .../add_entry_button.tsx | 8 +- .../document_entry_editor.tsx | 214 +++++---- .../helpers.ts | 4 + .../index.test.tsx | 244 ++++++++++ .../index.tsx | 103 ++-- .../index_entry_editor.test.tsx | 150 ++++++ .../index_entry_editor.tsx | 446 ++++++++++-------- .../translations.ts | 28 +- .../use_knowledge_base_table.tsx | 97 ++-- .../mock/test_providers/test_providers.tsx | 3 + .../mock/test_providers/test_providers.tsx | 3 + .../src/assistant/kibana_sub_features.ts | 41 ++ .../src/assistant/product_feature_config.ts | 5 +- .../features/src/product_features_keys.ts | 1 + .../create_knowledge_base_entry.ts | 9 + .../knowledge_base/index.ts | 15 +- .../server/ai_assistant_service/index.ts | 1 + .../knowledge_base/entries/create_route.ts | 1 - .../server/routes/request_context_factory.ts | 14 +- x-pack/plugins/security_solution/kibana.jsonc | 5 +- .../public/assistant/overlay.tsx | 20 +- .../public/assistant/provider.tsx | 2 + .../management_settings.test.tsx | 15 +- .../stack_management/management_settings.tsx | 90 +++- .../use_assistant_availability/index.tsx | 5 + .../common/mock/mock_assistant_provider.tsx | 3 + .../rule_status_failed_callout.test.tsx | 3 + .../right/hooks/use_assistant.test.tsx | 3 + .../plugins/security_solution/public/types.ts | 2 + .../plugins/security_solution/tsconfig.json | 2 + .../public/navigation/management_cards.ts | 35 +- .../apis/security/privileges.ts | 1 + .../apis/security/privileges_basic.ts | 1 + .../cypress/tasks/assistant.ts | 2 - 59 files changed, 1458 insertions(+), 794 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_modal.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx diff --git a/src/plugins/data_views/public/data_views/data_views_api_client.test.ts b/src/plugins/data_views/public/data_views/data_views_api_client.test.ts index 1ca1023423bea..8e1261802fbbc 100644 --- a/src/plugins/data_views/public/data_views/data_views_api_client.test.ts +++ b/src/plugins/data_views/public/data_views/data_views_api_client.test.ts @@ -17,6 +17,7 @@ describe('IndexPatternsApiClient', () => { let indexPatternsApiClient: DataViewsApiClient; beforeEach(() => { + jest.clearAllMocks(); fetchSpy = jest.spyOn(http, 'fetch').mockImplementation(() => Promise.resolve({})); indexPatternsApiClient = new DataViewsApiClient(http as HttpSetup, () => Promise.resolve(undefined) @@ -46,4 +47,15 @@ describe('IndexPatternsApiClient', () => { version: '1', // version header }); }); + + test('Correctly formats fieldTypes argument', async function () { + const fieldTypes = ['text', 'keyword']; + await indexPatternsApiClient.getFieldsForWildcard({ + pattern: 'blah', + fieldTypes, + allowHidden: false, + }); + + expect(fetchSpy.mock.calls[0][1].query.field_types).toEqual(fieldTypes); + }); }); diff --git a/src/plugins/data_views/public/data_views/data_views_api_client.ts b/src/plugins/data_views/public/data_views/data_views_api_client.ts index 3b91ebcbf5d78..e569e7f25bff6 100644 --- a/src/plugins/data_views/public/data_views/data_views_api_client.ts +++ b/src/plugins/data_views/public/data_views/data_views_api_client.ts @@ -112,7 +112,7 @@ export class DataViewsApiClient implements IDataViewsApiClient { allow_no_index: allowNoIndex, include_unmapped: includeUnmapped, fields, - fieldTypes, + field_types: fieldTypes, // default to undefined to keep value out of URL params and improve caching allow_hidden: allowHidden || undefined, include_empty_fields: includeEmptyFields, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index 1e43dcb889e9b..b9457e5cfea68 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -12,11 +12,7 @@ import useEvent from 'react-use/lib/useEvent'; import { css } from '@emotion/react'; // eslint-disable-next-line @kbn/eslint/module_migration import { createGlobalStyle } from 'styled-components'; -import { - ShowAssistantOverlayProps, - useAssistantContext, - UserAvatar, -} from '../../assistant_context'; +import { ShowAssistantOverlayProps, useAssistantContext } from '../../assistant_context'; import { Assistant, CONVERSATION_SIDE_PANEL_WIDTH } from '..'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; @@ -25,9 +21,6 @@ const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; * Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever * component currently has focus and any specific context it may provide through the SAssInterface. */ -export interface Props { - currentUserAvatar?: UserAvatar; -} export const UnifiedTimelineGlobalStyles = createGlobalStyle` body:has(.timeline-portal-overlay-mask) .euiOverlayMask { @@ -35,7 +28,7 @@ export const UnifiedTimelineGlobalStyles = createGlobalStyle` } `; -export const AssistantOverlay = React.memo<Props>(({ currentUserAvatar }) => { +export const AssistantOverlay = React.memo(() => { const [isModalVisible, setIsModalVisible] = useState(false); // Why is this named Title and not Id? const [conversationTitle, setConversationTitle] = useState<string | undefined>(undefined); @@ -144,7 +137,6 @@ export const AssistantOverlay = React.memo<Props>(({ currentUserAvatar }) => { onCloseFlyout={handleCloseModal} chatHistoryVisible={chatHistoryVisible} setChatHistoryVisible={toggleChatHistory} - currentUserAvatar={currentUserAvatar} /> </EuiFlyoutResizable> <UnifiedTimelineGlobalStyles /> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx index ac0109f31b9b7..b54f43c6a3aa4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/flyout/index.tsx @@ -28,6 +28,7 @@ interface Props { onSaveCancelled: () => void; onSaveConfirmed: () => void; saveButtonDisabled?: boolean; + saveButtonLoading?: boolean; } const FlyoutComponent: React.FC<Props> = ({ @@ -38,9 +39,11 @@ const FlyoutComponent: React.FC<Props> = ({ onSaveCancelled, onSaveConfirmed, saveButtonDisabled = false, + saveButtonLoading = false, }) => { return flyoutVisible ? ( <EuiFlyout + data-test-subj={'flyout'} ownFocus onClose={onClose} css={css` @@ -74,6 +77,7 @@ const FlyoutComponent: React.FC<Props> = ({ onClick={onSaveConfirmed} iconType="check" disabled={saveButtonDisabled} + isLoading={saveButtonLoading} fill > {i18n.FLYOUT_SAVE_BUTTON_TITLE} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx index f89ad5912a60a..06e0c8ebcc977 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/common/components/assistant_settings_management/inline_actions/index.tsx @@ -48,6 +48,7 @@ export const useInlineActions = <T extends { isDefault?: boolean | undefined }>( }, { name: i18n.DELETE_BUTTON, + 'data-test-subj': 'delete-button', description: i18n.DELETE_BUTTON, icon: 'trash', type: 'icon', diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index d042a4cfd96f5..08bac25c0a522 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -24,7 +24,6 @@ import { Conversation } from '../assistant_context/types'; import * as all from './chat_send/use_chat_send'; import { useConversation } from './use_conversation'; import { AIConnector } from '../connectorland/connector_selector'; -import { omit } from 'lodash'; jest.mock('../connectorland/use_load_connectors'); jest.mock('../connectorland/connector_setup'); @@ -141,111 +140,6 @@ describe('Assistant', () => { >); }); - describe('persistent storage', () => { - it('should refetchCurrentUserConversations after settings save button click', async () => { - const chatSendSpy = jest.spyOn(all, 'useChatSend'); - await renderAssistant(); - - fireEvent.click(screen.getByTestId('settings')); - - jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ - data: { - ...mockData, - welcome_id: { - ...mockData.welcome_id, - apiConfig: { newProp: true }, - }, - }, - isLoading: false, - refetch: jest.fn().mockResolvedValue({ - isLoading: false, - data: { - ...mockData, - welcome_id: { - ...mockData.welcome_id, - apiConfig: { newProp: true }, - }, - }, - }), - isFetched: true, - } as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>); - - await act(async () => { - fireEvent.click(screen.getByTestId('save-button')); - }); - - expect(chatSendSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentConversation: { - apiConfig: { newProp: true }, - category: 'assistant', - id: mockData.welcome_id.id, - messages: [], - title: 'Welcome', - replacements: {}, - }, - }) - ); - }); - - it('should refetchCurrentUserConversations after settings save button click, but do not update convos when refetch returns bad results', async () => { - jest.mocked(useFetchCurrentUserConversations).mockReturnValue({ - data: mockData, - isLoading: false, - refetch: jest.fn().mockResolvedValue({ - isLoading: false, - data: omit(mockData, 'welcome_id'), - }), - isFetched: true, - } as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>); - const chatSendSpy = jest.spyOn(all, 'useChatSend'); - await renderAssistant(); - - fireEvent.click(screen.getByTestId('settings')); - await act(async () => { - fireEvent.click(screen.getByTestId('save-button')); - }); - - expect(chatSendSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentConversation: { - apiConfig: { connectorId: '123' }, - replacements: {}, - category: 'assistant', - id: mockData.welcome_id.id, - messages: [], - title: 'Welcome', - }, - }) - ); - }); - - it('should delete conversation when delete button is clicked', async () => { - await renderAssistant(); - const deleteButton = screen.getAllByTestId('delete-option')[0]; - await act(async () => { - fireEvent.click(deleteButton); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); - }); - - await waitFor(() => { - expect(mockDeleteConvo).toHaveBeenCalledWith(mockData.electric_sheep_id.id); - }); - }); - it('should refetchCurrentUserConversations after clear chat history button click', async () => { - await renderAssistant(); - fireEvent.click(screen.getByTestId('chat-context-menu')); - fireEvent.click(screen.getByTestId('clear-chat')); - fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); - await waitFor(() => { - expect(clearConversation).toHaveBeenCalled(); - expect(refetchResults).toHaveBeenCalled(); - }); - }); - }); describe('when selected conversation changes and some connectors are loaded', () => { it('should persist the conversation id to local storage', async () => { const getConversation = jest.fn().mockResolvedValue(mockData.electric_sheep_id); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index c52d94138b839..b20122f822164 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -38,7 +38,7 @@ import { ChatSend } from './chat_send'; import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; import { getDefaultConnector } from './helpers'; -import { useAssistantContext, UserAvatar } from '../assistant_context'; +import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; @@ -61,7 +61,6 @@ const CommentContainer = styled('span')` export interface Props { chatHistoryVisible?: boolean; conversationTitle?: string; - currentUserAvatar?: UserAvatar; onCloseFlyout?: () => void; promptContextId?: string; setChatHistoryVisible?: Dispatch<SetStateAction<boolean>>; @@ -75,7 +74,6 @@ export interface Props { const AssistantComponent: React.FC<Props> = ({ chatHistoryVisible, conversationTitle, - currentUserAvatar, onCloseFlyout, promptContextId = '', setChatHistoryVisible, @@ -90,12 +88,10 @@ const AssistantComponent: React.FC<Props> = ({ getLastConversationId, http, promptContexts, - setCurrentUserAvatar, + currentUserAvatar, setLastConversationId, } = useAssistantContext(); - setCurrentUserAvatar(currentUserAvatar); - const [selectedPromptContexts, setSelectedPromptContexts] = useState< Record<string, SelectedPromptContext> >({}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx index 2a5cae76d5e77..b916fb348dd50 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.test.tsx @@ -31,10 +31,10 @@ describe('AlertsSettings', () => { ); const rangeSlider = screen.getByTestId('alertsRange'); - fireEvent.change(rangeSlider, { target: { value: '10' } }); + fireEvent.change(rangeSlider, { target: { value: '90' } }); expect(setUpdatedKnowledgeBaseSettings).toHaveBeenCalledWith({ - latestAlerts: 10, + latestAlerts: 90, }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_modal.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_modal.tsx new file mode 100644 index 0000000000000..4e362a4bec8be --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_modal.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { ALERTS_LABEL } from '../../../knowledge_base/translations'; +import { + DEFAULT_CONVERSATIONS, + DEFAULT_PROMPTS, + useSettingsUpdater, +} from '../use_settings_updater/use_settings_updater'; +import { AlertsSettings } from './alerts_settings'; +import { CANCEL, SAVE } from '../translations'; + +interface AlertSettingsModalProps { + onClose: () => void; +} + +export const AlertsSettingsModal = ({ onClose }: AlertSettingsModalProps) => { + const { knowledgeBase, setUpdatedKnowledgeBaseSettings, saveSettings } = useSettingsUpdater( + DEFAULT_CONVERSATIONS, // Alerts settings do not require conversations + DEFAULT_PROMPTS, // Alerts settings do not require prompts + false, // Alerts settings do not require conversations + false // Alerts settings do not require prompts + ); + + const handleSave = useCallback(() => { + saveSettings(); + onClose(); + }, [onClose, saveSettings]); + + return ( + <EuiModal onClose={onClose}> + <EuiModalHeader> + <EuiModalHeaderTitle>{ALERTS_LABEL}</EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <AlertsSettings + knowledgeBase={knowledgeBase} + setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} + /> + </EuiModalBody> + <EuiModalFooter> + <EuiButtonEmpty onClick={onClose}>{CANCEL}</EuiButtonEmpty> + <EuiButton type="submit" onClick={handleSave} fill> + {SAVE} + </EuiButton> + </EuiModalFooter> + </EuiModal> + ); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx index 9fb8db972e482..14bfcb4cdbbec 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.test.tsx @@ -64,12 +64,12 @@ jest.mock('../../assistant_context'); jest.mock('.', () => { return { - AnonymizationSettings: () => <span data-test-subj="ANONYMIZATION_TAB-tab" />, - ConversationSettings: () => <span data-test-subj="CONVERSATIONS_TAB-tab" />, - EvaluationSettings: () => <span data-test-subj="EVALUATION_TAB-tab" />, - KnowledgeBaseSettings: () => <span data-test-subj="KNOWLEDGE_BASE_TAB-tab" />, - QuickPromptSettings: () => <span data-test-subj="QUICK_PROMPTS_TAB-tab" />, - SystemPromptSettings: () => <span data-test-subj="SYSTEM_PROMPTS_TAB-tab" />, + AnonymizationSettings: () => <span data-test-subj="anonymization-tab" />, + ConversationSettings: () => <span data-test-subj="conversations-tab" />, + EvaluationSettings: () => <span data-test-subj="evaluation-tab" />, + KnowledgeBaseSettings: () => <span data-test-subj="knowledge_base-tab" />, + QuickPromptSettings: () => <span data-test-subj="quick_prompts-tab" />, + SystemPromptSettings: () => <span data-test-subj="system_prompts-tab" />, }; }); @@ -136,17 +136,6 @@ describe('AssistantSettings', () => { QUICK_PROMPTS_TAB, SYSTEM_PROMPTS_TAB, ])('%s', (tab) => { - it('Opens the tab on button click', () => { - (useAssistantContext as jest.Mock).mockImplementation(() => ({ - ...mockContext, - selectedSettingsTab: tab === CONVERSATIONS_TAB ? ANONYMIZATION_TAB : CONVERSATIONS_TAB, - })); - const { getByTestId } = render(<AssistantSettings {...testProps} />, { - wrapper, - }); - fireEvent.click(getByTestId(`${tab}-button`)); - expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab); - }); it('renders with the correct tab open', () => { (useAssistantContext as jest.Mock).mockImplementation(() => ({ ...mockContext, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx index f92ca3fc3c763..f325e411bae2b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings.tsx @@ -9,14 +9,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiButtonEmpty, - EuiIcon, EuiModal, EuiModalFooter, - EuiKeyPadMenu, - EuiKeyPadMenuItem, EuiPage, EuiPageBody, - EuiPageSidebar, EuiSplitPanel, } from '@elastic/eui'; @@ -80,13 +76,7 @@ export const AssistantSettings: React.FC<Props> = React.memo( conversations, conversationsLoaded, }) => { - const { - assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled }, - http, - toasts, - selectedSettingsTab, - setSelectedSettingsTab, - } = useAssistantContext(); + const { http, toasts, selectedSettingsTab, setSelectedSettingsTab } = useAssistantContext(); useEffect(() => { if (selectedSettingsTab == null) { @@ -211,112 +201,6 @@ export const AssistantSettings: React.FC<Props> = React.memo( return ( <StyledEuiModal data-test-subj={TEST_IDS.SETTINGS_MODAL} onClose={onClose}> <EuiPage paddingSize="none"> - <EuiPageSidebar - paddingSize="xs" - css={css` - min-inline-size: unset !important; - max-width: 104px; - `} - > - <EuiKeyPadMenu> - <EuiKeyPadMenuItem - id={CONVERSATIONS_TAB} - label={i18n.CONVERSATIONS_MENU_ITEM} - isSelected={!selectedSettingsTab || selectedSettingsTab === CONVERSATIONS_TAB} - onClick={() => setSelectedSettingsTab(CONVERSATIONS_TAB)} - data-test-subj={`${CONVERSATIONS_TAB}-button`} - > - <> - <EuiIcon - type="editorComment" - size="xl" - css={css` - position: relative; - top: -10px; - `} - /> - <EuiIcon - type="editorComment" - size="l" - css={css` - position: relative; - transform: rotateY(180deg); - top: -7px; - `} - /> - </> - </EuiKeyPadMenuItem> - <EuiKeyPadMenuItem - id={QUICK_PROMPTS_TAB} - label={i18n.QUICK_PROMPTS_MENU_ITEM} - isSelected={selectedSettingsTab === QUICK_PROMPTS_TAB} - onClick={() => setSelectedSettingsTab(QUICK_PROMPTS_TAB)} - data-test-subj={`${QUICK_PROMPTS_TAB}-button`} - > - <> - <EuiIcon type="editorComment" size="xxl" /> - <EuiIcon - type="bolt" - size="s" - color="warning" - css={css` - position: absolute; - top: 11px; - left: 14px; - `} - /> - </> - </EuiKeyPadMenuItem> - <EuiKeyPadMenuItem - id={SYSTEM_PROMPTS_TAB} - label={i18n.SYSTEM_PROMPTS_MENU_ITEM} - isSelected={selectedSettingsTab === SYSTEM_PROMPTS_TAB} - onClick={() => setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)} - data-test-subj={`${SYSTEM_PROMPTS_TAB}-button`} - > - <EuiIcon type="editorComment" size="xxl" /> - <EuiIcon - type="storage" - size="s" - color="success" - css={css` - position: absolute; - top: 11px; - left: 14px; - `} - /> - </EuiKeyPadMenuItem> - <EuiKeyPadMenuItem - id={ANONYMIZATION_TAB} - label={i18n.ANONYMIZATION_MENU_ITEM} - isSelected={selectedSettingsTab === ANONYMIZATION_TAB} - onClick={() => setSelectedSettingsTab(ANONYMIZATION_TAB)} - data-test-subj={`${ANONYMIZATION_TAB}-button`} - > - <EuiIcon type="eyeClosed" size="l" /> - </EuiKeyPadMenuItem> - <EuiKeyPadMenuItem - id={KNOWLEDGE_BASE_TAB} - label={i18n.KNOWLEDGE_BASE_MENU_ITEM} - isSelected={selectedSettingsTab === KNOWLEDGE_BASE_TAB} - onClick={() => setSelectedSettingsTab(KNOWLEDGE_BASE_TAB)} - data-test-subj={`${KNOWLEDGE_BASE_TAB}-button`} - > - <EuiIcon type="notebookApp" size="l" /> - </EuiKeyPadMenuItem> - {modelEvaluatorEnabled && ( - <EuiKeyPadMenuItem - id={EVALUATION_TAB} - label={i18n.EVALUATION_MENU_ITEM} - isSelected={selectedSettingsTab === EVALUATION_TAB} - onClick={() => setSelectedSettingsTab(EVALUATION_TAB)} - data-test-subj={`${EVALUATION_TAB}-button`} - > - <EuiIcon type="crossClusterReplicationApp" size="l" /> - </EuiKeyPadMenuItem> - )} - </EuiKeyPadMenu> - </EuiPageSidebar> <EuiPageBody paddingSize="none" panelled={true}> <EuiSplitPanel.Outer grow={true}> <EuiSplitPanel.Inner diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.test.tsx index 84ce96b829558..0b00a38282ebe 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.test.tsx @@ -11,7 +11,6 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c import { AssistantSettingsButton } from './assistant_settings_button'; import { welcomeConvo } from '../../mock/conversation'; -import { CONVERSATIONS_TAB } from './const'; const setIsSettingsModalVisible = jest.fn(); const onConversationSelected = jest.fn(); @@ -57,12 +56,6 @@ describe('AssistantSettingsButton', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('Clicking the settings gear opens the conversations tab', () => { - const { getByTestId } = render(<AssistantSettingsButton {...testProps} />); - fireEvent.click(getByTestId('settings')); - expect(setSelectedSettingsTab).toHaveBeenCalledWith(CONVERSATIONS_TAB); - expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true); - }); it('Settings modal is visible and calls correct actions per click', () => { const { getByTestId } = render( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx index 0767916d00ad7..40bf1e740ab60 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_button.tsx @@ -6,8 +6,6 @@ */ import React, { useCallback } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; - import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; import { DataStreamApis } from '../use_data_stream_apis'; import { AIConnector } from '../../connectorland/connector_selector'; @@ -15,7 +13,6 @@ import { Conversation } from '../../..'; import { AssistantSettings } from './assistant_settings'; import * as i18n from './translations'; import { useAssistantContext } from '../../assistant_context'; -import { CONVERSATIONS_TAB } from './const'; interface Props { defaultConnector?: AIConnector; @@ -48,7 +45,7 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo( refetchCurrentUserConversations, refetchPrompts, }) => { - const { toasts, setSelectedSettingsTab } = useAssistantContext(); + const { toasts } = useAssistantContext(); // Modal control functions const cleanupAndCloseModal = useCallback(() => { @@ -76,37 +73,18 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo( [cleanupAndCloseModal, refetchCurrentUserConversations, refetchPrompts, toasts] ); - const handleShowConversationSettings = useCallback(() => { - setSelectedSettingsTab(CONVERSATIONS_TAB); - setIsSettingsModalVisible(true); - }, [setIsSettingsModalVisible, setSelectedSettingsTab]); - return ( - <> - <EuiToolTip position="right" content={i18n.SETTINGS_TOOLTIP}> - <EuiButtonIcon - aria-label={i18n.SETTINGS} - data-test-subj="settings" - onClick={handleShowConversationSettings} - isDisabled={isDisabled} - iconType="gear" - size="xs" - color="text" - /> - </EuiToolTip> - - {isSettingsModalVisible && ( - <AssistantSettings - defaultConnector={defaultConnector} - selectedConversationId={selectedConversationId} - onConversationSelected={onConversationSelected} - onClose={handleCloseModal} - onSave={handleSave} - conversations={conversations} - conversationsLoaded={conversationsLoaded} - /> - )} - </> + isSettingsModalVisible && ( + <AssistantSettings + defaultConnector={defaultConnector} + selectedConversationId={selectedConversationId} + onConversationSelected={onConversationSelected} + onClose={handleCloseModal} + onSave={handleSave} + conversations={conversations} + conversationsLoaded={conversationsLoaded} + /> + ) ); } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx index dd472b3ee87ab..fe8c81ce1c404 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.test.tsx @@ -16,8 +16,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AssistantSettingsManagement } from './assistant_settings_management'; import { - ANONYMIZATION_TAB, CONNECTORS_TAB, + ANONYMIZATION_TAB, CONVERSATIONS_TAB, EVALUATION_TAB, KNOWLEDGE_BASE_TAB, @@ -40,15 +40,12 @@ const mockValues = { quickPromptSettings: [], }; -const setSelectedSettingsTab = jest.fn(); const mockContext = { basePromptContexts: MOCK_QUICK_PROMPTS, - setSelectedSettingsTab, http: { get: jest.fn(), }, assistantFeatures: { assistantModelEvaluation: true }, - selectedSettingsTab: null, assistantAvailability: { isAssistantEnabled: true, }, @@ -58,39 +55,42 @@ const mockDataViews = { getIndices: jest.fn(), } as unknown as DataViewsContract; +const onTabChange = jest.fn(); const testProps = { selectedConversation: welcomeConvo, dataViews: mockDataViews, + onTabChange, + currentTab: CONNECTORS_TAB, }; jest.mock('../../assistant_context'); jest.mock('../../connectorland/connector_settings_management', () => ({ - ConnectorsSettingsManagement: () => <span data-test-subj="CONNECTORS_TAB-tab" />, + ConnectorsSettingsManagement: () => <span data-test-subj="connectors-tab" />, })); jest.mock('../conversations/conversation_settings_management', () => ({ - ConversationSettingsManagement: () => <span data-test-subj="CONVERSATIONS_TAB-tab" />, + ConversationSettingsManagement: () => <span data-test-subj="conversations-tab" />, })); jest.mock('../quick_prompts/quick_prompt_settings_management', () => ({ - QuickPromptSettingsManagement: () => <span data-test-subj="QUICK_PROMPTS_TAB-tab" />, + QuickPromptSettingsManagement: () => <span data-test-subj="quick_prompts-tab" />, })); jest.mock('../prompt_editor/system_prompt/system_prompt_settings_management', () => ({ - SystemPromptSettingsManagement: () => <span data-test-subj="SYSTEM_PROMPTS_TAB-tab" />, + SystemPromptSettingsManagement: () => <span data-test-subj="system_prompts-tab" />, })); jest.mock('../../knowledge_base/knowledge_base_settings_management', () => ({ - KnowledgeBaseSettingsManagement: () => <span data-test-subj="KNOWLEDGE_BASE_TAB-tab" />, + KnowledgeBaseSettingsManagement: () => <span data-test-subj="knowledge_base-tab" />, })); jest.mock('../../data_anonymization/settings/anonymization_settings_management', () => ({ - AnonymizationSettingsManagement: () => <span data-test-subj="ANONYMIZATION_TAB-tab" />, + AnonymizationSettingsManagement: () => <span data-test-subj="anonymization-tab" />, })); jest.mock('.', () => { return { - EvaluationSettings: () => <span data-test-subj="EVALUATION_TAB-tab" />, + EvaluationSettings: () => <span data-test-subj="evaluation-tab" />, }; }); @@ -138,25 +138,23 @@ describe('AssistantSettingsManagement', () => { SYSTEM_PROMPTS_TAB, ])('%s', (tab) => { it('Opens the tab on button click', () => { - (useAssistantContext as jest.Mock).mockImplementation(() => ({ - ...mockContext, - selectedSettingsTab: tab, - })); - const { getByTestId } = render(<AssistantSettingsManagement {...testProps} />, { - wrapper, - }); + const { getByTestId } = render( + <AssistantSettingsManagement {...testProps} currentTab={tab} />, + { + wrapper, + } + ); fireEvent.click(getByTestId(`settingsPageTab-${tab}`)); - expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab); + expect(onTabChange).toHaveBeenCalledWith(tab); }); it('renders with the correct tab open', () => { - (useAssistantContext as jest.Mock).mockImplementation(() => ({ - ...mockContext, - selectedSettingsTab: tab, - })); - const { getByTestId } = render(<AssistantSettingsManagement {...testProps} />, { - wrapper, - }); - expect(getByTestId(`${tab}-tab`)).toBeInTheDocument(); + const { getByTestId } = render( + <AssistantSettingsManagement {...testProps} currentTab={tab} />, + { + wrapper, + } + ); + expect(getByTestId(`tab-${tab}`)).toBeInTheDocument(); }); }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx index 4c50d14a5662e..12b26da336e72 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/assistant_settings_management.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiAvatar, EuiPageTemplate, EuiTitle, useEuiShadow, useEuiTheme } from '@elastic/eui'; - import { css } from '@emotion/react'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; import { Conversation } from '../../..'; @@ -32,10 +31,13 @@ import { } from './const'; import { KnowledgeBaseSettingsManagement } from '../../knowledge_base/knowledge_base_settings_management'; import { EvaluationSettings } from '.'; +import { SettingsTabs } from './types'; interface Props { dataViews: DataViewsContract; selectedConversation: Conversation; + onTabChange?: (tabId: string) => void; + currentTab?: SettingsTabs; } /** @@ -43,14 +45,16 @@ interface Props { * anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag. */ export const AssistantSettingsManagement: React.FC<Props> = React.memo( - ({ dataViews, selectedConversation: defaultSelectedConversation }) => { + ({ + dataViews, + selectedConversation: defaultSelectedConversation, + onTabChange, + currentTab: selectedSettingsTab, + }) => { const { assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled }, http, - selectedSettingsTab, - setSelectedSettingsTab, } = useAssistantContext(); - const { data: connectors } = useLoadConnectors({ http, }); @@ -59,12 +63,6 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo( const { euiTheme } = useEuiTheme(); const headerIconShadow = useEuiShadow('s'); - useEffect(() => { - if (selectedSettingsTab == null) { - setSelectedSettingsTab(CONNECTORS_TAB); - } - }, [selectedSettingsTab, setSelectedSettingsTab]); - const tabsConfig = useMemo( () => [ { @@ -107,10 +105,12 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo( return tabsConfig.map((t) => ({ ...t, 'data-test-subj': `settingsPageTab-${t.id}`, - onClick: () => setSelectedSettingsTab(t.id), + onClick: () => { + onTabChange?.(t.id); + }, isSelected: t.id === selectedSettingsTab, })); - }, [setSelectedSettingsTab, selectedSettingsTab, tabsConfig]); + }, [onTabChange, selectedSettingsTab, tabsConfig]); return ( <> @@ -143,6 +143,7 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo( padding-top: ${euiTheme.base * 0.75}px; padding-bottom: ${euiTheme.base * 0.75}px; `} + data-test-subj={`tab-${selectedSettingsTab}`} > {selectedSettingsTab === CONNECTORS_TAB && <ConnectorsSettingsManagement />} {selectedSettingsTab === CONVERSATIONS_TAB && ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/const.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/const.ts index c61a6dda8d235..c753c04fd6e60 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/const.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/const.ts @@ -4,12 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -export const CONNECTORS_TAB = 'CONNECTORS_TAB' as const; -export const CONVERSATIONS_TAB = 'CONVERSATIONS_TAB' as const; -export const QUICK_PROMPTS_TAB = 'QUICK_PROMPTS_TAB' as const; -export const SYSTEM_PROMPTS_TAB = 'SYSTEM_PROMPTS_TAB' as const; -export const ANONYMIZATION_TAB = 'ANONYMIZATION_TAB' as const; -export const KNOWLEDGE_BASE_TAB = 'KNOWLEDGE_BASE_TAB' as const; -export const EVALUATION_TAB = 'EVALUATION_TAB' as const; +export const CONNECTORS_TAB = 'connectors' as const; +export const CONVERSATIONS_TAB = 'conversations' as const; +export const QUICK_PROMPTS_TAB = 'quick_prompts' as const; +export const SYSTEM_PROMPTS_TAB = 'system_prompts' as const; +export const ANONYMIZATION_TAB = 'anonymization' as const; +export const KNOWLEDGE_BASE_TAB = 'knowledge_base' as const; +export const EVALUATION_TAB = 'evaluation' as const; export const DEFAULT_PAGE_SIZE = 25; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx index b7f33b9a6af5a..3a19a68643006 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/settings_context_menu/settings_context_menu.tsx @@ -18,8 +18,11 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; +import { AnonymizationSettingsManagement } from '../../../data_anonymization/settings/anonymization_settings_management'; import { useAssistantContext } from '../../../..'; import * as i18n from '../../assistant_header/translations'; +import { AlertsSettingsModal } from '../alerts_settings/alerts_settings_modal'; +import { KNOWLEDGE_BASE_TAB } from '../const'; interface Params { isDisabled?: boolean; @@ -37,6 +40,15 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( const [isPopoverOpen, setPopover] = useState(false); const [isResetConversationModalVisible, setIsResetConversationModalVisible] = useState(false); + + const [isAlertsSettingsModalVisible, setIsAlertsSettingsModalVisible] = useState(false); + const closeAlertSettingsModal = useCallback(() => setIsAlertsSettingsModalVisible(false), []); + const showAlertSettingsModal = useCallback(() => setIsAlertsSettingsModalVisible(true), []); + + const [isAnonymizationModalVisible, setIsAnonymizationModalVisible] = useState(false); + const closeAnonymizationModal = useCallback(() => setIsAnonymizationModalVisible(false), []); + const showAnonymizationModal = useCallback(() => setIsAnonymizationModalVisible(true), []); + const closeDestroyModal = useCallback(() => setIsResetConversationModalVisible(false), []); const onButtonClick = useCallback(() => { @@ -60,14 +72,24 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( [navigateToApp] ); + const handleNavigateToAnonymization = useCallback(() => { + showAnonymizationModal(); + closePopover(); + }, [closePopover, showAnonymizationModal]); + const handleNavigateToKnowledgeBase = useCallback( () => navigateToApp('management', { - path: 'kibana/securityAiAssistantManagement', + path: `kibana/securityAiAssistantManagement?tab=${KNOWLEDGE_BASE_TAB}`, }), [navigateToApp] ); + const handleShowAlertsModal = useCallback(() => { + showAlertSettingsModal(); + closePopover(); + }, [closePopover, showAlertSettingsModal]); + // We are migrating away from the settings modal in favor of the new Stack Management UI // Currently behind `assistantKnowledgeBaseByDefault` FF const newItems: ReactElement[] = useMemo( @@ -80,14 +102,6 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( > {i18n.AI_ASSISTANT_SETTINGS} </EuiContextMenuItem>, - <EuiContextMenuItem - aria-label={'anonymization'} - onClick={handleNavigateToSettings} - icon={'eye'} - data-test-subj={'anonymization'} - > - {i18n.ANONYMIZATION} - </EuiContextMenuItem>, <EuiContextMenuItem aria-label={'knowledge-base'} onClick={handleNavigateToKnowledgeBase} @@ -96,9 +110,17 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( > {i18n.KNOWLEDGE_BASE} </EuiContextMenuItem>, + <EuiContextMenuItem + aria-label={'anonymization'} + onClick={handleNavigateToAnonymization} + icon={'eye'} + data-test-subj={'anonymization'} + > + {i18n.ANONYMIZATION} + </EuiContextMenuItem>, <EuiContextMenuItem aria-label={'alerts-to-analyze'} - onClick={handleNavigateToSettings} + onClick={handleShowAlertsModal} icon={'magnifyWithExclamation'} data-test-subj={'alerts-to-analyze'} > @@ -112,7 +134,13 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( </EuiFlexGroup> </EuiContextMenuItem>, ], - [handleNavigateToKnowledgeBase, handleNavigateToSettings, knowledgeBase] + [ + handleNavigateToAnonymization, + handleNavigateToKnowledgeBase, + handleNavigateToSettings, + handleShowAlertsModal, + knowledgeBase.latestAlerts, + ] ); const items = useMemo( @@ -164,6 +192,10 @@ export const SettingsContextMenu: React.FC<Params> = React.memo( `} /> </EuiPopover> + {isAlertsSettingsModalVisible && <AlertsSettingsModal onClose={closeAlertSettingsModal} />} + {isAnonymizationModalVisible && ( + <AnonymizationSettingsManagement modalMode onClose={closeAnonymizationModal} /> + )} {isResetConversationModalVisible && ( <EuiConfirmModal title={i18n.RESET_CONVERSATION} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx index 92a2a3df2683b..6e4a114c14256 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -21,7 +21,7 @@ export const SYSTEM_PROMPT_TABLE_SESSION_STORAGE_KEY = 'systemPromptTable'; export const ANONYMIZATION_TABLE_SESSION_STORAGE_KEY = 'anonymizationTable'; /** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */ -export const DEFAULT_LATEST_ALERTS = 20; +export const DEFAULT_LATEST_ALERTS = 100; /** The default maximum number of alerts to be sent as context when generating Attack discoveries */ export const DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS = 200; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 2319bf67de89a..9ac817e03973a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -14,7 +14,8 @@ import useLocalStorage from 'react-use/lib/useLocalStorage'; import useSessionStorage from 'react-use/lib/useSessionStorage'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; -import { NavigateToAppOptions } from '@kbn/core/public'; +import { NavigateToAppOptions, UserProfileService } from '@kbn/core/public'; +import { useQuery } from '@tanstack/react-query'; import { updatePromptContexts } from './helpers'; import type { PromptContext, @@ -75,6 +76,7 @@ export interface AssistantProviderProps { title?: string; toasts?: IToasts; currentAppId: string; + userProfileService: UserProfileService; } export interface UserAvatar { @@ -108,7 +110,6 @@ export interface UseAssistantContext { registerPromptContext: RegisterPromptContext; selectedSettingsTab: SettingsTabs | null; setAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean | undefined>>; - setCurrentUserAvatar: React.Dispatch<React.SetStateAction<UserAvatar | undefined>>; setKnowledgeBase: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig | undefined>>; setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>; setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs | null>>; @@ -126,6 +127,7 @@ export interface UseAssistantContext { unRegisterPromptContext: UnRegisterPromptContext; currentAppId: string; codeBlockRef: React.MutableRefObject<(codeBlock: string) => void>; + userProfileService: UserProfileService; } const AssistantContext = React.createContext<UseAssistantContext | undefined>(undefined); @@ -148,6 +150,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ title = DEFAULT_ASSISTANT_TITLE, toasts, currentAppId, + userProfileService, }) => { /** * Session storage for traceOptions, including APM URL and LangSmith Project/API Key @@ -224,7 +227,18 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ /** * Current User Avatar */ - const [currentUserAvatar, setCurrentUserAvatar] = useState<UserAvatar>(); + const { data: currentUserAvatar } = useQuery({ + queryKey: ['currentUserAvatar'], + queryFn: async () => + userProfileService.getCurrent<{ avatar: UserAvatar }>({ + dataPath: 'avatar', + }), + select: (data) => { + return data.data.avatar; + }, + keepPreviousData: true, + refetchOnWindowFocus: false, + }); /** * Settings State @@ -275,7 +289,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ assistantStreamingEnabled: localStorageStreaming ?? true, setAssistantStreamingEnabled: setLocalStorageStreaming, setKnowledgeBase: setLocalStorageKnowledgeBase, - setCurrentUserAvatar, setSelectedSettingsTab, setShowAssistantOverlay, setTraceOptions: setSessionStorageTraceOptions, @@ -289,6 +302,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ baseConversations, currentAppId, codeBlockRef, + userProfileService, }), [ actionTypeRegistry, @@ -323,6 +337,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({ baseConversations, currentAppId, codeBlockRef, + userProfileService, ] ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx index dad5ef04e0c18..80996bbf80d68 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/types.tsx @@ -69,6 +69,8 @@ export interface AssistantAvailability { hasConnectorsReadPrivilege: boolean; // When true, user has `Edit` privilege for `AnonymizationFields` hasUpdateAIAssistantAnonymization: boolean; + // When true, user has `Edit` privilege for `Global Knowledge Base` + hasManageGlobalKnowledgeBase: boolean; } export type GetAssistantMessages = (commentArgs: { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.test.tsx index 5465ca19e99de..69e3df940d285 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_missing_callout/index.test.tsx @@ -20,6 +20,7 @@ describe('connectorMissingCallout', () => { hasConnectorsAllPrivilege: false, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }; @@ -58,6 +59,7 @@ describe('connectorMissingCallout', () => { hasConnectorsAllPrivilege: false, hasConnectorsReadPrivilege: false, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: false, isAssistantEnabled: true, }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx index 191b9c0e3d90b..375d03581cb39 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings/index.test.tsx @@ -78,6 +78,7 @@ const mockUseAssistantContext = { ], assistantAvailability: { hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, }, baseAllow: ['@timestamp', 'event.category', 'user.name'], baseAllowReplacement: ['user.name', 'host.ip'], diff --git a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_management/index.tsx index 5fca3c6996d2f..bb6ed94f546f0 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/data_anonymization/settings/anonymization_settings_management/index.tsx @@ -5,7 +5,19 @@ * 2.0. */ -import { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import React, { useCallback, useState } from 'react'; import { euiThemeVars } from '@kbn/ui-theme'; @@ -25,13 +37,23 @@ import { import { useFetchAnonymizationFields } from '../../../assistant/api/anonymization_fields/use_fetch_anonymization_fields'; import { AssistantSettingsBottomBar } from '../../../assistant/settings/assistant_settings_bottom_bar'; import { useAssistantContext } from '../../../assistant_context'; -import { SETTINGS_UPDATED_TOAST_TITLE } from '../../../assistant/settings/translations'; +import { + CANCEL, + SAVE, + SETTINGS_UPDATED_TOAST_TITLE, +} from '../../../assistant/settings/translations'; export interface Props { defaultPageSize?: number; + modalMode?: boolean; + onClose?: () => void; } -const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPageSize = 5 }) => { +const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ + defaultPageSize = 5, + modalMode = false, + onClose, +}) => { const { toasts } = useAssistantContext(); const { data: anonymizationFields } = useFetchAnonymizationFields(); const [hasPendingChanges, setHasPendingChanges] = useState(false); @@ -52,9 +74,10 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage ); const onCancelClick = useCallback(() => { + onClose?.(); resetSettings(); setHasPendingChanges(false); - }, [resetSettings]); + }, [onClose, resetSettings]); const handleSave = useCallback( async (param?: { callback?: () => void }) => { @@ -71,7 +94,8 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage const onSaveButtonClicked = useCallback(() => { handleSave(); - }, [handleSave]); + onClose?.(); + }, [handleSave, onClose]); const handleAnonymizationFieldsBulkActions = useCallback< UseAnonymizationListUpdateProps['setAnonymizationFieldsBulkActions'] @@ -99,6 +123,47 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage setAnonymizationFieldsBulkActions: handleAnonymizationFieldsBulkActions, setUpdatedAnonymizationData: handleUpdatedAnonymizationData, }); + + if (modalMode) { + return ( + <EuiModal onClose={onCancelClick}> + <EuiModalHeader> + <EuiModalHeaderTitle>{i18n.SETTINGS_TITLE}</EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <EuiText size="m">{i18n.SETTINGS_DESCRIPTION}</EuiText> + + <EuiSpacer size="m" /> + + <EuiFlexGroup alignItems="center" data-test-subj="summary" gutterSize="none"> + <Stats + isDataAnonymizable={true} + anonymizationFields={updatedAnonymizationData.data} + titleSize="m" + gap={euiThemeVars.euiSizeS} + /> + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <ContextEditor + anonymizationFields={updatedAnonymizationData} + compressed={false} + onListUpdated={onListUpdated} + rawData={null} + pageSize={defaultPageSize} + /> + </EuiModalBody> + <EuiModalFooter> + <EuiButtonEmpty onClick={onCancelClick}>{CANCEL}</EuiButtonEmpty> + <EuiButton type="submit" onClick={onSaveButtonClicked} fill disabled={!hasPendingChanges}> + {SAVE} + </EuiButton> + </EuiModalFooter> + </EuiModal> + ); + } + return ( <> <EuiPanel hasShadow={false} hasBorder paddingSize="l"> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx index 6cfa60eff282d..98a4de601ab98 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -66,6 +66,7 @@ export const AlertsRange: React.FC<Props> = React.memo( return ( <EuiRange aria-label={ALERTS_RANGE} + fullWidth compressed={compressed} css={css` max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx index aa873decdcd87..a46ba652574f6 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings.tsx @@ -36,13 +36,14 @@ const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-bas interface Props { knowledgeBase: KnowledgeBaseConfig; setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>; + modalMode?: boolean; } /** * Knowledge Base Settings -- set up the Knowledge Base and configure RAG on alerts */ export const KnowledgeBaseSettings: React.FC<Props> = React.memo( - ({ knowledgeBase, setUpdatedKnowledgeBaseSettings }) => { + ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, modalMode = false }) => { const { http, toasts } = useAssistantContext(); const { data: kbStatus, isLoading, isFetching } = useKnowledgeBaseStatus({ http }); const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts }); @@ -113,7 +114,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo( return ( <> - <EuiTitle size={'s'}> + <EuiTitle size={'s'} data-test-subj="knowledge-base-settings"> <h2> {i18n.SETTINGS_TITLE}{' '} <EuiBetaBadge iconType={'beaker'} label={i18n.SETTINGS_BADGE} size="s" color="hollow" /> @@ -194,10 +195,12 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo( <EuiSpacer size="s" /> - <AlertsSettings - knowledgeBase={knowledgeBase} - setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} - /> + {!modalMode && ( + <AlertsSettings + knowledgeBase={knowledgeBase} + setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} + /> + )} </> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx index 5b3ec4562d086..46f9f0cddf6f4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/add_entry_button.tsx @@ -58,6 +58,7 @@ export const AddEntryButton: React.FC<Props> = React.memo( aria-label={i18n.DOCUMENT} key={i18n.DOCUMENT} icon="document" + data-test-subj="addDocument" onClick={handleDocumentClicked} disabled={!isDocumentAvailable} > @@ -67,7 +68,12 @@ export const AddEntryButton: React.FC<Props> = React.memo( return onIndexClicked || onDocumentClicked ? ( <EuiPopover button={ - <EuiButton iconType="arrowDown" iconSide="right" onClick={onButtonClick}> + <EuiButton + data-test-subj="addEntry" + iconType="arrowDown" + iconSide="right" + onClick={onButtonClick} + > <EuiIcon type="plusInCircle" /> {i18n.NEW} </EuiButton> diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx index b33f221bfde3b..11d9ac2d62289 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/document_entry_editor.tsx @@ -21,116 +21,124 @@ import * as i18n from './translations'; interface Props { entry?: DocumentEntry; setEntry: React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>>; + hasManageGlobalKnowledgeBase: boolean; } -export const DocumentEntryEditor: React.FC<Props> = React.memo(({ entry, setEntry }) => { - // Name - const setName = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => - setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), - [setEntry] - ); +export const DocumentEntryEditor: React.FC<Props> = React.memo( + ({ entry, setEntry, hasManageGlobalKnowledgeBase }) => { + // Name + const setName = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => + setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), + [setEntry] + ); - // Sharing - const setSharingOptions = useCallback( - (value: string) => - setEntry((prevEntry) => ({ - ...prevEntry, - users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, - })), - [setEntry] - ); - // TODO: KB-RBAC Disable global option if no RBAC - const sharingOptions = [ - { - value: i18n.SHARING_PRIVATE_OPTION_LABEL, - inputDisplay: ( - <EuiText size={'s'}> - <EuiIcon - color="subdued" - style={{ lineHeight: 'inherit', marginRight: '4px' }} - type="lock" - /> - {i18n.SHARING_PRIVATE_OPTION_LABEL} - </EuiText> - ), - }, - { - value: i18n.SHARING_GLOBAL_OPTION_LABEL, - inputDisplay: ( - <EuiText size={'s'}> - <EuiIcon - color="subdued" - style={{ lineHeight: 'inherit', marginRight: '4px' }} - type="globe" - /> - {i18n.SHARING_GLOBAL_OPTION_LABEL} - </EuiText> - ), - }, - ]; - const selectedSharingOption = - entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; + // Sharing + const setSharingOptions = useCallback( + (value: string) => + setEntry((prevEntry) => ({ + ...prevEntry, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + })), + [setEntry] + ); + const sharingOptions = [ + { + value: i18n.SHARING_PRIVATE_OPTION_LABEL, + inputDisplay: ( + <EuiText size={'s'}> + <EuiIcon + color="subdued" + style={{ lineHeight: 'inherit', marginRight: '4px' }} + type="lock" + /> + {i18n.SHARING_PRIVATE_OPTION_LABEL} + </EuiText> + ), + }, + { + value: i18n.SHARING_GLOBAL_OPTION_LABEL, + inputDisplay: ( + <EuiText size={'s'}> + <EuiIcon + color="subdued" + style={{ lineHeight: 'inherit', marginRight: '4px' }} + type="globe" + /> + {i18n.SHARING_GLOBAL_OPTION_LABEL} + </EuiText> + ), + disabled: !hasManageGlobalKnowledgeBase, + }, + ]; + const selectedSharingOption = + entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; - // Text / markdown - const setMarkdownValue = useCallback( - (value: string) => { - setEntry((prevEntry) => ({ ...prevEntry, text: value })); - }, - [setEntry] - ); + // Text / markdown + const setMarkdownValue = useCallback( + (value: string) => { + setEntry((prevEntry) => ({ ...prevEntry, text: value })); + }, + [setEntry] + ); - // Required checkbox - const onRequiredKnowledgeChanged = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked })); - }, - [setEntry] - ); + // Required checkbox + const onRequiredKnowledgeChanged = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked })); + }, + [setEntry] + ); - return ( - <EuiForm> - <EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth> - <EuiFieldText - name="name" - placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER} + return ( + <EuiForm> + <EuiFormRow + label={i18n.ENTRY_NAME_INPUT_LABEL} + helpText={i18n.ENTRY_NAME_INPUT_PLACEHOLDER} fullWidth - value={entry?.name} - onChange={setName} - /> - </EuiFormRow> - <EuiFormRow - label={i18n.ENTRY_SHARING_INPUT_LABEL} - helpText={i18n.SHARING_HELP_TEXT} - fullWidth - > - <EuiSuperSelect - options={sharingOptions} - valueOfSelected={selectedSharingOption} - onChange={setSharingOptions} + > + <EuiFieldText + name="name" + data-test-subj="entryNameInput" + fullWidth + value={entry?.name} + onChange={setName} + /> + </EuiFormRow> + <EuiFormRow + label={i18n.ENTRY_SHARING_INPUT_LABEL} + helpText={i18n.SHARING_HELP_TEXT} fullWidth - /> - </EuiFormRow> - <EuiFormRow label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} fullWidth> - <EuiMarkdownEditor - aria-label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} - placeholder="# Title" - value={entry?.text ?? ''} - onChange={setMarkdownValue} - height={400} - initialViewMode={'editing'} - /> - </EuiFormRow> - <EuiFormRow fullWidth helpText={i18n.ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT}> - <EuiCheckbox - label={i18n.ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL} - id="requiredKnowledge" - onChange={onRequiredKnowledgeChanged} - checked={entry?.required ?? false} - /> - </EuiFormRow> - </EuiForm> - ); -}); + > + <EuiSuperSelect + options={sharingOptions} + valueOfSelected={selectedSharingOption} + onChange={setSharingOptions} + fullWidth + /> + </EuiFormRow> + <EuiFormRow label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} fullWidth> + <EuiMarkdownEditor + aria-label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} + data-test-subj="entryMarkdownInput" + placeholder="# Title" + value={entry?.text ?? ''} + onChange={setMarkdownValue} + height={400} + initialViewMode={'editing'} + /> + </EuiFormRow> + <EuiFormRow fullWidth helpText={i18n.ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT}> + <EuiCheckbox + label={i18n.ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL} + id="requiredKnowledge" + onChange={onRequiredKnowledgeChanged} + checked={entry?.required ?? false} + /> + </EuiFormRow> + </EuiForm> + ); + } +); DocumentEntryEditor.displayName = 'DocumentEntryEditor'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts index 75d66a355d781..456eebfaffb57 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/helpers.ts @@ -23,6 +23,10 @@ export const isSystemEntry = ( ); }; +export const isGlobalEntry = ( + entry: KnowledgeBaseEntryResponse +): entry is KnowledgeBaseEntryResponse => entry.users != null && !entry.users.length; + export const isKnowledgeBaseEntryCreateProps = ( entry: unknown ): entry is z.infer<typeof KnowledgeBaseEntryCreateProps> => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx new file mode 100644 index 0000000000000..86cc30ea02943 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.test.tsx @@ -0,0 +1,244 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { KnowledgeBaseSettingsManagement } from '.'; +import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry'; +import { useDeleteKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries'; +import { useFlyoutModalVisibility } from '../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility'; +import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries'; +import { + isKnowledgeBaseSetup, + useKnowledgeBaseStatus, +} from '../../assistant/api/knowledge_base/use_knowledge_base_status'; +import { useSettingsUpdater } from '../../assistant/settings/use_settings_updater/use_settings_updater'; +import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'; +import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt'; +import { useAssistantContext } from '../../..'; +import { I18nProvider } from '@kbn/i18n-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const mockContext = { + basePromptContexts: MOCK_QUICK_PROMPTS, + setSelectedSettingsTab: jest.fn(), + http: { + get: jest.fn(), + }, + assistantFeatures: { assistantKnowledgeBaseByDefault: true }, + selectedSettingsTab: null, + assistantAvailability: { + isAssistantEnabled: true, + }, +}; +jest.mock('../../assistant_context'); +jest.mock('../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry'); +jest.mock('../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'); +jest.mock('../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries'); + +jest.mock('../../assistant/settings/use_settings_updater/use_settings_updater'); +jest.mock('../../assistant/api/knowledge_base/use_knowledge_base_status'); +jest.mock('../../assistant/api/knowledge_base/entries/use_knowledge_base_entries'); +jest.mock( + '../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility' +); +const mockDataViews = { + getIndices: jest.fn().mockResolvedValue([{ name: 'index-1' }, { name: 'index-2' }]), + getFieldsForWildcard: jest.fn().mockResolvedValue([ + { name: 'field-1', esTypes: ['semantic_text'] }, + { name: 'field-2', esTypes: ['text'] }, + { name: 'field-3', esTypes: ['semantic_text'] }, + ]), +} as unknown as DataViewsContract; +const queryClient = new QueryClient(); +const wrapper = (props: { children: React.ReactNode }) => ( + <I18nProvider> + <QueryClientProvider client={queryClient}>{props.children}</QueryClientProvider> + </I18nProvider> +); +describe('KnowledgeBaseSettingsManagement', () => { + const mockData = [ + { id: '1', name: 'Test Entry 1', type: 'document', kbResource: 'user', users: [{ id: 'hi' }] }, + { id: '2', name: 'Test Entry 2', type: 'index', kbResource: 'global', users: [] }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + (useAssistantContext as jest.Mock).mockImplementation(() => mockContext); + (useSettingsUpdater as jest.Mock).mockReturnValue({ + knowledgeBase: { latestAlerts: 20 }, + setUpdatedKnowledgeBaseSettings: jest.fn(), + resetSettings: jest.fn(), + saveSettings: jest.fn(), + }); + (isKnowledgeBaseSetup as jest.Mock).mockReturnValue(true); + (useKnowledgeBaseStatus as jest.Mock).mockReturnValue({ + data: { + elser_exists: true, + security_labs_exists: true, + index_exists: true, + pipeline_exists: true, + }, + isFetched: true, + }); + (useKnowledgeBaseEntries as jest.Mock).mockReturnValue({ + data: { data: mockData }, + isFetching: false, + refetch: jest.fn(), + }); + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: false, + openFlyout: jest.fn(), + closeFlyout: jest.fn(), + }); + (useCreateKnowledgeBaseEntry as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn(), + isLoading: false, + }); + (useUpdateKnowledgeBaseEntries as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn(), + isLoading: false, + }); + (useDeleteKnowledgeBaseEntries as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn(), + isLoading: false, + }); + }); + it('renders old kb settings when enableKnowledgeBaseByDefault is not enabled', () => { + (useAssistantContext as jest.Mock).mockImplementation(() => ({ + ...mockContext, + assistantFeatures: { + assistantKnowledgeBaseByDefault: false, + }, + })); + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { wrapper }); + + expect(screen.getByTestId('knowledge-base-settings')).toBeInTheDocument(); + }); + it('renders loading spinner when data is not fetched', () => { + (useKnowledgeBaseStatus as jest.Mock).mockReturnValue({ data: {}, isFetched: false }); + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + expect(screen.getByTestId('spinning')).toBeInTheDocument(); + }); + + it('Prompts user to set up knowledge base when isKbSetup', async () => { + (useKnowledgeBaseStatus as jest.Mock).mockReturnValue({ + data: { + elser_exists: false, + security_labs_exists: false, + index_exists: false, + pipeline_exists: false, + }, + isFetched: true, + }); + (isKnowledgeBaseSetup as jest.Mock).mockReturnValue(false); + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + expect(screen.getByTestId('setup-knowledge-base-button')).toBeInTheDocument(); + }); + + it('renders knowledge base table with entries', async () => { + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + waitFor(() => { + expect(screen.getByTestId('knowledge-base-entries-table')).toBeInTheDocument(); + expect(screen.getByText('Test Entry 1')).toBeInTheDocument(); + expect(screen.getByText('Test Entry 2')).toBeInTheDocument(); + }); + }); + + it('opens the flyout when add document button is clicked', async () => { + const openFlyoutMock = jest.fn(); + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: false, + openFlyout: openFlyoutMock, + closeFlyout: jest.fn(), + }); + + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('addEntry')); + }); + await waitFor(() => { + fireEvent.click(screen.getByTestId('addDocument')); + }); + expect(openFlyoutMock).toHaveBeenCalled(); + }); + + it('refreshes table on refresh button click', async () => { + const refetchMock = jest.fn(); + (useKnowledgeBaseEntries as jest.Mock).mockReturnValue({ + data: { data: mockData }, + isFetching: false, + refetch: refetchMock, + }); + + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('refresh-entries')); + }); + expect(refetchMock).toHaveBeenCalled(); + }); + + it('handles save and cancel actions for the flyout', async () => { + const closeFlyoutMock = jest.fn(); + (useFlyoutModalVisibility as jest.Mock).mockReturnValue({ + isFlyoutOpen: true, + openFlyout: jest.fn(), + closeFlyout: closeFlyoutMock, + }); + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('addEntry')); + }); + await waitFor(() => { + fireEvent.click(screen.getByTestId('addDocument')); + }); + + expect(screen.getByTestId('flyout')).toBeVisible(); + + await userEvent.type(screen.getByTestId('entryNameInput'), 'hi'); + + await waitFor(() => { + fireEvent.click(screen.getByTestId('cancel-button')); + }); + + expect(closeFlyoutMock).toHaveBeenCalled(); + }); + + it('handles delete confirmation modal actions', async () => { + render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { + wrapper, + }); + + await waitFor(() => { + fireEvent.click(screen.getAllByTestId('delete-button')[0]); + }); + expect(screen.getByTestId('delete-entry-confirmation')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); + }); + expect(screen.queryByTestId('delete-entry-confirmation')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx index 5cf887ae3375d..b199039b4efae 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index.tsx @@ -7,6 +7,7 @@ import { EuiButton, + EuiConfirmModal, EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, @@ -52,15 +53,17 @@ import { isSystemEntry, isKnowledgeBaseEntryCreateProps, isKnowledgeBaseEntryResponse, + isGlobalEntry, } from './helpers'; import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry'; import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries'; -import { SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations'; +import { DELETE, SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations'; import { KnowledgeBaseConfig } from '../../assistant/types'; import { isKnowledgeBaseSetup, useKnowledgeBaseStatus, } from '../../assistant/api/knowledge_base/use_knowledge_base_status'; +import { CANCEL_BUTTON_TEXT } from '../../assistant/assistant_header/translations'; interface Params { dataViews: DataViewsContract; @@ -69,6 +72,7 @@ interface Params { export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ dataViews }) => { const { assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault }, + assistantAvailability: { hasManageGlobalKnowledgeBase }, http, toasts, } = useAssistantContext(); @@ -76,6 +80,8 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d const { data: kbStatus, isFetched } = useKnowledgeBaseStatus({ http }); const isKbSetup = isKnowledgeBaseSetup(kbStatus); + const [deleteKBItem, setDeleteKBItem] = useState<DocumentEntry | IndexEntry | null>(null); + // Only needed for legacy settings management const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } = useSettingsUpdater( @@ -123,24 +129,28 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d useState<Partial<DocumentEntry | IndexEntry | KnowledgeBaseEntryCreateProps>>(); // CRUD API accessors - const { mutate: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({ - http, - toasts, - }); - const { mutate: updateEntries, isLoading: isUpdatingEntries } = useUpdateKnowledgeBaseEntries({ + const { mutateAsync: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({ http, toasts, }); - const { mutate: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({ + const { mutateAsync: updateEntries, isLoading: isUpdatingEntries } = + useUpdateKnowledgeBaseEntries({ + http, + toasts, + }); + const { mutateAsync: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({ http, toasts, }); const isModifyingEntry = isCreatingEntry || isUpdatingEntries || isDeletingEntries; // Flyout Save/Cancel Actions - const onSaveConfirmed = useCallback(() => { + const onSaveConfirmed = useCallback(async () => { if (isKnowledgeBaseEntryResponse(selectedEntry)) { - updateEntries([selectedEntry]); + await updateEntries([selectedEntry]); + closeFlyout(); + } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { + await createEntry(selectedEntry); closeFlyout(); } else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) { createEntry(selectedEntry); @@ -166,19 +176,19 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d const columns = useMemo( () => getColumns({ - onEntryNameClicked: ({ id }: KnowledgeBaseEntryResponse) => { - const entry = entries.data.find((e) => e.id === id); - setSelectedEntry(entry); - openFlyout(); - }, isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => { - return !isSystemEntry(entry); + return ( + !isSystemEntry(entry) && (isGlobalEntry(entry) ? hasManageGlobalKnowledgeBase : true) + ); }, - onDeleteActionClicked: ({ id }: KnowledgeBaseEntryResponse) => { - deleteEntry({ ids: [id] }); + // Add delete popover + onDeleteActionClicked: (item: KnowledgeBaseEntryResponse) => { + setDeleteKBItem(item); }, isEditEnabled: (entry: KnowledgeBaseEntryResponse) => { - return !isSystemEntry(entry); + return ( + !isSystemEntry(entry) && (isGlobalEntry(entry) ? hasManageGlobalKnowledgeBase : true) + ); }, onEditActionClicked: ({ id }: KnowledgeBaseEntryResponse) => { const entry = entries.data.find((e) => e.id === id); @@ -186,7 +196,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d openFlyout(); }, }), - [deleteEntry, entries.data, getColumns, openFlyout] + [entries.data, getColumns, hasManageGlobalKnowledgeBase, openFlyout] ); // Refresh button @@ -214,6 +224,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d <EuiFlexItem> <EuiButton color={'text'} + data-test-subj={'refresh-entries'} isDisabled={isFetchingEntries} onClick={handleRefreshTable} iconType={'refresh'} @@ -251,6 +262,24 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d : i18n.NEW_INDEX_FLYOUT_TITLE; }, [selectedEntry]); + const sorting = { + sort: { + field: 'name', + direction: 'desc' as const, + }, + }; + + const handleCancelDeleteEntry = useCallback(() => { + setDeleteKBItem(null); + }, [setDeleteKBItem]); + + const handleDeleteEntry = useCallback(async () => { + if (deleteKBItem?.id) { + await deleteEntry({ ids: [deleteKBItem?.id] }); + setDeleteKBItem(null); + } + }, [deleteEntry, deleteKBItem, setDeleteKBItem]); + if (!enableKnowledgeBaseByDefault) { return ( <> @@ -267,13 +296,6 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d ); } - const sorting = { - sort: { - field: 'name', - direction: 'desc' as const, - }, - }; - return ( <> <EuiPanel hasShadow={false} hasBorder paddingSize="l"> @@ -298,9 +320,10 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d <EuiFlexGroup justifyContent="spaceAround"> <EuiFlexItem grow={false}> {!isFetched ? ( - <EuiLoadingSpinner size="l" /> + <EuiLoadingSpinner data-test-subj="spinning" size="l" /> ) : isKbSetup ? ( <EuiInMemoryTable + data-test-subj="knowledge-base-entries-table" columns={columns} items={entries.data ?? []} search={search} @@ -344,7 +367,13 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d onClose={onSaveCancelled} onSaveCancelled={onSaveCancelled} onSaveConfirmed={onSaveConfirmed} - saveButtonDisabled={!isKnowledgeBaseEntryCreateProps(selectedEntry) || isModifyingEntry} // TODO: KB-RBAC disable for global entries if user doesn't have global RBAC + saveButtonDisabled={ + !isKnowledgeBaseEntryCreateProps(selectedEntry) || + (selectedEntry.users != null && + !selectedEntry.users.length && + !hasManageGlobalKnowledgeBase) + } + saveButtonLoading={isModifyingEntry} > <> {selectedEntry?.type === DocumentEntryType.value ? ( @@ -353,6 +382,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d setEntry={ setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>> } + hasManageGlobalKnowledgeBase={hasManageGlobalKnowledgeBase} /> ) : ( <IndexEntryEditor @@ -361,10 +391,27 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d setEntry={ setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<IndexEntry>>> } + hasManageGlobalKnowledgeBase={hasManageGlobalKnowledgeBase} /> )} </> </Flyout> + {deleteKBItem && ( + <EuiConfirmModal + data-test-subj="delete-entry-confirmation" + title={i18n.DELETE_ENTRY_CONFIRMATION_TITLE(deleteKBItem.name)} + onCancel={handleCancelDeleteEntry} + onConfirm={handleDeleteEntry} + cancelButtonText={CANCEL_BUTTON_TEXT} + confirmButtonText={DELETE} + buttonColor="danger" + defaultFocusedButton="cancel" + confirmButtonDisabled={isModifyingEntry} + isLoading={isModifyingEntry} + > + <p>{i18n.DELETE_ENTRY_CONFIRMATION_CONTENT}</p> + </EuiConfirmModal> + )} </> ); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx new file mode 100644 index 0000000000000..d4634cdf4c563 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.test.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, fireEvent, waitFor, within } from '@testing-library/react'; +import { IndexEntryEditor } from './index_entry_editor'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { IndexEntry } from '@kbn/elastic-assistant-common'; +import * as i18n from './translations'; + +describe('IndexEntryEditor', () => { + const mockSetEntry = jest.fn(); + const mockDataViews = { + getIndices: jest.fn().mockResolvedValue([{ name: 'index-1' }, { name: 'index-2' }]), + getFieldsForWildcard: jest.fn().mockResolvedValue([ + { name: 'field-1', esTypes: ['semantic_text'] }, + { name: 'field-2', esTypes: ['text'] }, + { name: 'field-3', esTypes: ['semantic_text'] }, + ]), + } as unknown as DataViewsContract; + + const defaultProps = { + dataViews: mockDataViews, + setEntry: mockSetEntry, + hasManageGlobalKnowledgeBase: true, + entry: { + name: 'Test Entry', + index: 'index-1', + field: 'field-1', + description: 'Test Description', + queryDescription: 'Test Query Description', + users: [], + } as unknown as IndexEntry, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the form fields with initial values', () => { + const { getByDisplayValue } = render(<IndexEntryEditor {...defaultProps} />); + + waitFor(() => { + expect(getByDisplayValue('Test Entry')).toBeInTheDocument(); + expect(getByDisplayValue('Test Description')).toBeInTheDocument(); + expect(getByDisplayValue('Test Query Description')).toBeInTheDocument(); + expect(getByDisplayValue('index-1')).toBeInTheDocument(); + expect(getByDisplayValue('field-1')).toBeInTheDocument(); + }); + }); + + it('updates the name field on change', () => { + const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />); + + waitFor(() => { + const nameInput = getByTestId('entry-name'); + fireEvent.change(nameInput, { target: { value: 'New Entry Name' } }); + }); + + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('updates the description field on change', () => { + const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />); + waitFor(() => { + const descriptionInput = getByTestId('entry-description'); + fireEvent.change(descriptionInput, { target: { value: 'New Description' } }); + }); + + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('updates the query description field on change', () => { + const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />); + waitFor(() => { + const queryDescriptionInput = getByTestId('query-description'); + fireEvent.change(queryDescriptionInput, { target: { value: 'New Query Description' } }); + }); + + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('displays sharing options and updates on selection', async () => { + const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />); + + await waitFor(() => { + fireEvent.click(getByTestId('sharing-select')); + fireEvent.click(getByTestId('sharing-private-option')); + }); + await waitFor(() => { + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it('fetches index options and updates on selection', async () => { + const { getAllByTestId, getByTestId } = render(<IndexEntryEditor {...defaultProps} />); + + await waitFor(() => expect(mockDataViews.getIndices).toHaveBeenCalled()); + + await waitFor(() => { + fireEvent.click(getByTestId('index-combobox')); + fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]); + }); + fireEvent.click(getByTestId('index-2')); + + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('fetches field options based on selected index and updates on selection', async () => { + const { getByTestId, getAllByTestId } = render(<IndexEntryEditor {...defaultProps} />); + + await waitFor(() => + expect(mockDataViews.getFieldsForWildcard).toHaveBeenCalledWith({ + pattern: 'index-1', + fieldTypes: ['semantic_text'], + }) + ); + + await waitFor(() => { + fireEvent.click(getByTestId('index-combobox')); + fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]); + }); + fireEvent.click(getByTestId('index-2')); + + await waitFor(() => { + fireEvent.click(getByTestId('entry-combobox')); + }); + + await userEvent.type( + within(getByTestId('entry-combobox')).getByTestId('comboBoxSearchInput'), + 'field-3' + ); + expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('disables the field combo box if no index is selected', () => { + const { getByRole } = render( + <IndexEntryEditor {...defaultProps} entry={{ ...defaultProps.entry, index: '' }} /> + ); + + waitFor(() => { + expect(getByRole('combobox', { name: i18n.ENTRY_FIELD_PLACEHOLDER })).toBeDisabled(); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx index f5dd2df3bcaac..7475ea55ca5fc 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/index_entry_editor.tsx @@ -12,9 +12,11 @@ import { EuiFormRow, EuiComboBoxOptionOption, EuiText, + EuiTextArea, EuiIcon, EuiSuperSelect, } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; import React, { useCallback } from 'react'; import { IndexEntry } from '@kbn/elastic-assistant-common'; import { DataViewsContract } from '@kbn/data-views-plugin/public'; @@ -24,200 +26,270 @@ interface Props { dataViews: DataViewsContract; entry?: IndexEntry; setEntry: React.Dispatch<React.SetStateAction<Partial<IndexEntry>>>; + hasManageGlobalKnowledgeBase: boolean; } -export const IndexEntryEditor: React.FC<Props> = React.memo(({ dataViews, entry, setEntry }) => { - // Name - const setName = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => - setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), - [setEntry] - ); - - // Sharing - const setSharingOptions = useCallback( - (value: string) => - setEntry((prevEntry) => ({ - ...prevEntry, - users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, - })), - [setEntry] - ); - // TODO: KB-RBAC Disable global option if no RBAC - const sharingOptions = [ - { - value: i18n.SHARING_PRIVATE_OPTION_LABEL, - inputDisplay: ( - <EuiText size={'s'}> - <EuiIcon - color="subdued" - style={{ lineHeight: 'inherit', marginRight: '4px' }} - type="lock" - /> - {i18n.SHARING_PRIVATE_OPTION_LABEL} - </EuiText> - ), - }, - { - value: i18n.SHARING_GLOBAL_OPTION_LABEL, - inputDisplay: ( - <EuiText size={'s'}> - <EuiIcon - color="subdued" - style={{ lineHeight: 'inherit', marginRight: '4px' }} - type="globe" - /> - {i18n.SHARING_GLOBAL_OPTION_LABEL} - </EuiText> - ), - }, - ]; - const selectedSharingOption = - entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; - - // Index - // TODO: For index field autocomplete - // const indexOptions = useMemo(() => { - // const indices = await dataViews.getIndices({ - // pattern: e[0]?.value ?? '', - // isRollupIndex: () => false, - // }); - // }, [dataViews]); - const setIndex = useCallback( - async (e: Array<EuiComboBoxOptionOption<string>>) => { - setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value })); - }, - [setEntry] - ); - - const onCreateOption = (searchValue: string) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); - - if (!normalizedSearchValue) { - return; - } - - const newOption: EuiComboBoxOptionOption<string> = { - label: searchValue, - value: searchValue, +export const IndexEntryEditor: React.FC<Props> = React.memo( + ({ dataViews, entry, setEntry, hasManageGlobalKnowledgeBase }) => { + // Name + const setName = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => + setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })), + [setEntry] + ); + + // Sharing + const setSharingOptions = useCallback( + (value: string) => + setEntry((prevEntry) => ({ + ...prevEntry, + users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined, + })), + [setEntry] + ); + const sharingOptions = [ + { + 'data-test-subj': 'sharing-private-option', + value: i18n.SHARING_PRIVATE_OPTION_LABEL, + inputDisplay: ( + <EuiText size={'s'}> + <EuiIcon + color="subdued" + style={{ lineHeight: 'inherit', marginRight: '4px' }} + type="lock" + /> + {i18n.SHARING_PRIVATE_OPTION_LABEL} + </EuiText> + ), + }, + { + 'data-test-subj': 'sharing-global-option', + value: i18n.SHARING_GLOBAL_OPTION_LABEL, + inputDisplay: ( + <EuiText size={'s'}> + <EuiIcon + color="subdued" + style={{ lineHeight: 'inherit', marginRight: '4px' }} + type="globe" + /> + {i18n.SHARING_GLOBAL_OPTION_LABEL} + </EuiText> + ), + disabled: !hasManageGlobalKnowledgeBase, + }, + ]; + + const selectedSharingOption = + entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value; + + // Index + const indexOptions = useAsync(async () => { + const indices = await dataViews.getIndices({ + pattern: '*', + isRollupIndex: () => false, + }); + + return indices.map((index) => ({ + 'data-test-subj': index.name, + label: index.name, + value: index.name, + })); + }, [dataViews]); + + const fieldOptions = useAsync(async () => { + const fields = await dataViews.getFieldsForWildcard({ + pattern: entry?.index ?? '', + fieldTypes: ['semantic_text'], + }); + + return fields + .filter((field) => field.esTypes?.includes('semantic_text')) + .map((field) => ({ + 'data-test-subj': field.name, + label: field.name, + value: field.name, + })); + }, [entry]); + + const setIndex = useCallback( + async (e: Array<EuiComboBoxOptionOption<string>>) => { + setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value })); + }, + [setEntry] + ); + + const onCreateOption = (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption<string> = { + label: searchValue, + value: searchValue, + }; + + setIndex([newOption]); + setField([{ label: '', value: '' }]); }; - setIndex([newOption]); - }; - - // Field - const setField = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => - setEntry((prevEntry) => ({ ...prevEntry, field: e.target.value })), - [setEntry] - ); - - // Description - const setDescription = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => - setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })), - [setEntry] - ); - - // Query Description - const setQueryDescription = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => - setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })), - [setEntry] - ); - - return ( - <EuiForm> - <EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth> - <EuiFieldText - name="name" - placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER} - fullWidth - value={entry?.name} - onChange={setName} - /> - </EuiFormRow> - <EuiFormRow - label={i18n.ENTRY_SHARING_INPUT_LABEL} - helpText={i18n.SHARING_HELP_TEXT} - fullWidth - > - <EuiSuperSelect - options={sharingOptions} - valueOfSelected={selectedSharingOption} - onChange={setSharingOptions} - fullWidth - /> - </EuiFormRow> - <EuiFormRow label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} fullWidth> - <EuiComboBox - aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} - isClearable={true} - singleSelection={{ asPlainText: true }} - onCreateOption={onCreateOption} + const onCreateFieldOption = (searchValue: string) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption: EuiComboBoxOptionOption<string> = { + label: searchValue, + value: searchValue, + }; + + setField([newOption]); + }; + + // Field + const setField = useCallback( + async (e: Array<EuiComboBoxOptionOption<string>>) => + setEntry((prevEntry) => ({ ...prevEntry, field: e[0]?.value })), + [setEntry] + ); + + // Description + const setDescription = useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => + setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })), + [setEntry] + ); + + // Query Description + const setQueryDescription = useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => + setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })), + [setEntry] + ); + + return ( + <EuiForm> + <EuiFormRow + label={i18n.ENTRY_NAME_INPUT_LABEL} + helpText={i18n.ENTRY_NAME_INPUT_PLACEHOLDER} fullWidth - selectedOptions={ - entry?.index - ? [ - { - label: entry?.index, - value: entry?.index, - }, - ] - : [] - } - onChange={setIndex} - /> - </EuiFormRow> - <EuiFormRow label={i18n.ENTRY_FIELD_INPUT_LABEL} fullWidth> - <EuiFieldText - name="field" - placeholder={i18n.ENTRY_FIELD_PLACEHOLDER} + > + <EuiFieldText + data-test-subj="entry-name" + name="name" + fullWidth + value={entry?.name} + onChange={setName} + /> + </EuiFormRow> + <EuiFormRow + label={i18n.ENTRY_SHARING_INPUT_LABEL} + helpText={i18n.SHARING_HELP_TEXT} fullWidth - value={entry?.field} - onChange={setField} - /> - </EuiFormRow> - <EuiFormRow - label={i18n.ENTRY_DESCRIPTION_INPUT_LABEL} - helpText={i18n.ENTRY_DESCRIPTION_HELP_LABEL} - fullWidth - > - <EuiFieldText - name="description" + > + <EuiSuperSelect + data-test-subj="sharing-select" + options={sharingOptions} + valueOfSelected={selectedSharingOption} + onChange={setSharingOptions} + fullWidth + /> + </EuiFormRow> + <EuiFormRow label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} fullWidth> + <EuiComboBox + data-test-subj="index-combobox" + aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} + isClearable={true} + singleSelection={{ asPlainText: true }} + onCreateOption={onCreateOption} + fullWidth + options={indexOptions.value ?? []} + selectedOptions={ + entry?.index + ? [ + { + label: entry?.index, + value: entry?.index, + }, + ] + : [] + } + onChange={setIndex} + /> + </EuiFormRow> + <EuiFormRow label={i18n.ENTRY_FIELD_INPUT_LABEL} fullWidth> + <EuiComboBox + aria-label={i18n.ENTRY_FIELD_PLACEHOLDER} + data-test-subj="entry-combobox" + isClearable={true} + singleSelection={{ asPlainText: true }} + onCreateOption={onCreateFieldOption} + fullWidth + options={fieldOptions.value ?? []} + selectedOptions={ + entry?.field + ? [ + { + label: entry?.field, + value: entry?.field, + }, + ] + : [] + } + onChange={setField} + isDisabled={!entry?.index} + /> + </EuiFormRow> + <EuiFormRow + label={i18n.ENTRY_DESCRIPTION_INPUT_LABEL} + helpText={i18n.ENTRY_DESCRIPTION_HELP_LABEL} fullWidth - value={entry?.description} - onChange={setDescription} - /> - </EuiFormRow> - <EuiFormRow - label={i18n.ENTRY_QUERY_DESCRIPTION_INPUT_LABEL} - helpText={i18n.ENTRY_QUERY_DESCRIPTION_HELP_LABEL} - fullWidth - > - <EuiFieldText - name="description" + > + <EuiTextArea + name="description" + fullWidth + placeholder={i18n.ENTRY_DESCRIPTION_PLACEHOLDER} + data-test-subj="entry-description" + value={entry?.description} + onChange={setDescription} + rows={2} + /> + </EuiFormRow> + <EuiFormRow + label={i18n.ENTRY_QUERY_DESCRIPTION_INPUT_LABEL} + helpText={i18n.ENTRY_QUERY_DESCRIPTION_HELP_LABEL} fullWidth - value={entry?.queryDescription} - onChange={setQueryDescription} - /> - </EuiFormRow> - <EuiFormRow - label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL} - helpText={i18n.ENTRY_OUTPUT_FIELDS_HELP_LABEL} - fullWidth - > - <EuiComboBox - aria-label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL} - isClearable={true} - singleSelection={{ asPlainText: true }} - onCreateOption={onCreateOption} + > + <EuiTextArea + name="query_description" + placeholder={i18n.ENTRY_QUERY_DESCRIPTION_PLACEHOLDER} + data-test-subj="query-description" + value={entry?.queryDescription} + onChange={setQueryDescription} + fullWidth + rows={3} + /> + </EuiFormRow> + <EuiFormRow + label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL} + helpText={i18n.ENTRY_OUTPUT_FIELDS_HELP_LABEL} fullWidth - selectedOptions={[]} - onChange={setIndex} - /> - </EuiFormRow> - </EuiForm> - ); -}); + > + <EuiComboBox + aria-label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL} + isClearable={true} + singleSelection={{ asPlainText: true }} + onCreateOption={onCreateOption} + fullWidth + selectedOptions={[]} + onChange={setIndex} + /> + </EuiFormRow> + </EuiForm> + ); + } +); IndexEntryEditor.displayName = 'IndexEntryEditor'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts index 0cc16089fdaae..077426884eb8a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/translations.ts @@ -212,6 +212,13 @@ export const DELETE_ENTRY_CONFIRMATION_TITLE = (title: string) => } ); +export const DELETE_ENTRY_CONFIRMATION_CONTENT = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryContent', + { + defaultMessage: "You will not be able to recover this knowledge base entry once it's deleted.", + } +); + export const ENTRY_MARKDOWN_INPUT_TEXT = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryMarkdownInputText', { @@ -258,8 +265,14 @@ export const ENTRY_DESCRIPTION_INPUT_LABEL = i18n.translate( export const ENTRY_DESCRIPTION_HELP_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionHelpLabel', { - defaultMessage: - 'A description of the type of data in this index and/or when the assistant should look for data here.', + defaultMessage: 'Describe when this custom knowledge should be used during a conversation.', + } +); + +export const ENTRY_DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionPlaceholder', + { + defaultMessage: 'Use this index to answer any question related to asset information.', } ); @@ -273,7 +286,16 @@ export const ENTRY_QUERY_DESCRIPTION_INPUT_LABEL = i18n.translate( export const ENTRY_QUERY_DESCRIPTION_HELP_LABEL = i18n.translate( 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionHelpLabel', { - defaultMessage: 'Any instructions for extracting the search query from the user request.', + defaultMessage: + 'Describe what query should be constructed by the model to retrieve this custom knowledge.', + } +); + +export const ENTRY_QUERY_DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionPlaceholder', + { + defaultMessage: + 'Key terms to retrieve asset related information, like host names, IP Addresses or cloud objects.', } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx index d0038169cd597..67157b3ae7b12 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/knowledge_base_settings_management/use_knowledge_base_table.tsx @@ -7,21 +7,69 @@ import { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { FormattedDate } from '@kbn/i18n-react'; import { DocumentEntryType, IndexEntryType, KnowledgeBaseEntryResponse, } from '@kbn/elastic-assistant-common'; + +import useAsync from 'react-use/lib/useAsync'; import { useAssistantContext } from '../../..'; import * as i18n from './translations'; import { BadgesColumn } from '../../assistant/common/components/assistant_settings_management/badges'; import { useInlineActions } from '../../assistant/common/components/assistant_settings_management/inline_actions'; import { isSystemEntry } from './helpers'; +const AuthorColumn = ({ entry }: { entry: KnowledgeBaseEntryResponse }) => { + const { currentUserAvatar, userProfileService } = useAssistantContext(); + + const userProfile = useAsync(async () => { + const profile = await userProfileService?.bulkGet({ uids: new Set([entry.createdBy]) }); + return profile?.[0].user.username; + }, []); + + const userName = useMemo(() => userProfile?.value ?? 'Unknown', [userProfile?.value]); + const badgeItem = isSystemEntry(entry) ? 'Elastic' : userName; + const userImage = isSystemEntry(entry) ? ( + <EuiIcon + type={'logoElastic'} + css={css` + margin-left: 4px; + margin-right: 14px; + `} + /> + ) : currentUserAvatar?.imageUrl != null ? ( + <EuiAvatar + name={userName} + imageUrl={currentUserAvatar.imageUrl} + size={'s'} + color={currentUserAvatar?.color ?? 'subdued'} + css={css` + margin-right: 10px; + `} + /> + ) : ( + <EuiAvatar + name={userName} + initials={currentUserAvatar?.initials} + size={'s'} + color={currentUserAvatar?.color ?? 'subdued'} + css={css` + margin-right: 10px; + `} + /> + ); + return ( + <> + {userImage} + <EuiText size={'s'}>{badgeItem}</EuiText> + </> + ); +}; + export const useKnowledgeBaseTable = () => { - const { currentUserAvatar } = useAssistantContext(); const getActions = useInlineActions<KnowledgeBaseEntryResponse & { isDefault?: undefined }>(); const getIconForEntry = (entry: KnowledgeBaseEntryResponse): string => { @@ -43,13 +91,11 @@ export const useKnowledgeBaseTable = () => { ({ isDeleteEnabled, isEditEnabled, - onEntryNameClicked, onDeleteActionClicked, onEditActionClicked, }: { isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => boolean; isEditEnabled: (entry: KnowledgeBaseEntryResponse) => boolean; - onEntryNameClicked: (entry: KnowledgeBaseEntryResponse) => void; onDeleteActionClicked: (entry: KnowledgeBaseEntryResponse) => void; onEditActionClicked: (entry: KnowledgeBaseEntryResponse) => void; }): Array<EuiBasicTableColumn<KnowledgeBaseEntryResponse>> => { @@ -78,46 +124,7 @@ export const useKnowledgeBaseTable = () => { { name: i18n.COLUMN_AUTHOR, sortable: ({ users }: KnowledgeBaseEntryResponse) => users[0]?.name, - render: (entry: KnowledgeBaseEntryResponse) => { - // TODO: Look up user from `createdBy` id if privileges allow - const userName = entry.users?.[0]?.name ?? 'Unknown'; - const badgeItem = isSystemEntry(entry) ? 'Elastic' : userName; - const userImage = isSystemEntry(entry) ? ( - <EuiIcon - type={'logoElastic'} - css={css` - margin-left: 4px; - margin-right: 14px; - `} - /> - ) : currentUserAvatar?.imageUrl != null ? ( - <EuiAvatar - name={userName} - imageUrl={currentUserAvatar.imageUrl} - size={'s'} - color={currentUserAvatar?.color ?? 'subdued'} - css={css` - margin-right: 10px; - `} - /> - ) : ( - <EuiAvatar - name={userName} - initials={currentUserAvatar?.initials} - size={'s'} - color={currentUserAvatar?.color ?? 'subdued'} - css={css` - margin-right: 10px; - `} - /> - ); - return ( - <> - {userImage} - <EuiText size={'s'}>{badgeItem}</EuiText> - </> - ); - }, + render: (entry: KnowledgeBaseEntryResponse) => <AuthorColumn entry={entry} />, }, { name: i18n.COLUMN_ENTRIES, @@ -157,7 +164,7 @@ export const useKnowledgeBaseTable = () => { }, ]; }, - [currentUserAvatar, getActions] + [getActions] ); return { getColumns }; }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index 13e543a02b3b2..763085cca2688 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -14,6 +14,7 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UserProfileService } from '@kbn/core/public'; import { AssistantProvider, AssistantProviderProps } from '../../assistant_context'; import { AssistantAvailability } from '../../assistant_context/types'; @@ -31,6 +32,7 @@ export const mockAssistantAvailability: AssistantAvailability = { hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }; @@ -82,6 +84,7 @@ export const TestProvidersComponent: React.FC<Props> = ({ navigateToApp={mockNavigateToApp} {...providerContext} currentAppId={'test'} + userProfileService={jest.fn() as unknown as UserProfileService} > {children} </AssistantProvider> diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx index 316355f51c537..17b73f1e6dcd0 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx @@ -16,6 +16,7 @@ import { ThemeProvider } from 'styled-components'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Theme } from '@elastic/charts'; +import { UserProfileService } from '@kbn/core/public'; import { DataQualityProvider, DataQualityProviderProps } from '../../data_quality_context'; import { ResultsRollupContext } from '../../contexts/results_rollup_context'; import { IndicesCheckContext } from '../../contexts/indices_check_context'; @@ -48,6 +49,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({ hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }; const queryClient = new QueryClient({ @@ -81,6 +83,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({ baseConversations={{}} navigateToApp={mockNavigateToApp} currentAppId={'securitySolutionUI'} + userProfileService={jest.fn() as unknown as UserProfileService} > {children} </AssistantProvider> diff --git a/x-pack/packages/security-solution/features/src/assistant/kibana_sub_features.ts b/x-pack/packages/security-solution/features/src/assistant/kibana_sub_features.ts index f06e6cf55d9ff..d116aa36d21f0 100644 --- a/x-pack/packages/security-solution/features/src/assistant/kibana_sub_features.ts +++ b/x-pack/packages/security-solution/features/src/assistant/kibana_sub_features.ts @@ -48,8 +48,48 @@ const updateAnonymizationSubFeature: SubFeatureConfig = { ], }; +const manageGlobalKnowledgeBaseSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureName', + { + defaultMessage: 'Knowledge Base', + } + ), + description: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDescription', + { + defaultMessage: + 'Make changes to any space level (global) custom knowledge base entries. This will also allow users to modify global entries created by other users.', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: [`${APP_ID}-manageGlobalKnowledgeBaseAIAssistant`], + id: 'manage_global_knowledge_base', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDetails', + { + defaultMessage: 'Allow Changes to Global Entries', + } + ), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['manageGlobalKnowledgeBaseAIAssistant'], + }, + ], + }, + ], +}; + export enum AssistantSubFeatureId { updateAnonymization = 'updateAnonymizationSubFeature', + manageGlobalKnowledgeBase = 'manageGlobalKnowledgeBaseSubFeature', } /** @@ -65,5 +105,6 @@ export const getAssistantBaseKibanaSubFeatureIds = (): AssistantSubFeatureId[] = export const assistantSubFeaturesMap = Object.freeze( new Map<AssistantSubFeatureId, SubFeatureConfig>([ [AssistantSubFeatureId.updateAnonymization, updateAnonymizationSubFeature], + [AssistantSubFeatureId.manageGlobalKnowledgeBase, manageGlobalKnowledgeBaseSubFeature], ]) ); diff --git a/x-pack/packages/security-solution/features/src/assistant/product_feature_config.ts b/x-pack/packages/security-solution/features/src/assistant/product_feature_config.ts index fbac20c6e8b39..67c352afcfed7 100644 --- a/x-pack/packages/security-solution/features/src/assistant/product_feature_config.ts +++ b/x-pack/packages/security-solution/features/src/assistant/product_feature_config.ts @@ -28,6 +28,9 @@ export const assistantDefaultProductFeaturesConfig: Record< ui: ['ai-assistant'], }, }, - subFeatureIds: [AssistantSubFeatureId.updateAnonymization], + subFeatureIds: [ + AssistantSubFeatureId.updateAnonymization, + AssistantSubFeatureId.manageGlobalKnowledgeBase, + ], }, }; diff --git a/x-pack/packages/security-solution/features/src/product_features_keys.ts b/x-pack/packages/security-solution/features/src/product_features_keys.ts index 6000c110d9298..e72e669716c59 100644 --- a/x-pack/packages/security-solution/features/src/product_features_keys.ts +++ b/x-pack/packages/security-solution/features/src/product_features_keys.ts @@ -153,4 +153,5 @@ export enum CasesSubFeatureId { /** Sub-features IDs for Security Assistant */ export enum AssistantSubFeatureId { updateAnonymization = 'updateAnonymizationSubFeature', + manageGlobalKnowledgeBase = 'manageGlobalKnowledgeBaseSubFeature', } diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts index aef66d406bf74..23f73501b1056 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts @@ -171,6 +171,15 @@ export const getUpdateScript = ({ if (params.assignEmpty == true || params.containsKey('text')) { ctx._source.text = params.text; } + if (params.assignEmpty == true || params.containsKey('description')) { + ctx._source.description = params.description; + } + if (params.assignEmpty == true || params.containsKey('field')) { + ctx._source.field = params.field; + } + if (params.assignEmpty == true || params.containsKey('index')) { + ctx._source.index = params.index; + } ctx._source.updated_at = params.updated_at; ctx._source.updated_by = params.updated_by; `, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts index a13000242dada..64e7b00089c08 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts @@ -54,6 +54,7 @@ import { loadSecurityLabs } from '../../lib/langchain/content_loaders/security_l export interface GetAIAssistantKnowledgeBaseDataClientParams { modelIdOverride?: string; v2KnowledgeBaseEnabled?: boolean; + manageGlobalKnowledgeBaseAIAssistant?: boolean; } interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams { @@ -63,6 +64,7 @@ interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams { ingestPipelineResourceName: string; setIsKBSetupInProgress: (isInProgress: boolean) => void; v2KnowledgeBaseEnabled: boolean; + manageGlobalKnowledgeBaseAIAssistant: boolean; } export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { constructor(public readonly options: KnowledgeBaseDataClientParams) { @@ -307,12 +309,16 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { const writer = await this.getWriter(); const changedAt = new Date().toISOString(); const authenticatedUser = this.options.currentUser; - // TODO: KB-RBAC check for when `global:true` if (authenticatedUser == null) { throw new Error( 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' ); } + + if (global && !this.options.manageGlobalKnowledgeBaseAIAssistant) { + throw new Error('User lacks privileges to create global knowledge base entries'); + } + const { errors, docs_created: docsCreated } = await writer.bulk({ documentsToCreate: documents.map((doc) => { // v1 schema has metadata nested in a `metadata` object @@ -521,12 +527,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient { global?: boolean; }): Promise<KnowledgeBaseEntryResponse | null> => { const authenticatedUser = this.options.currentUser; - // TODO: KB-RBAC check for when `global:true` + if (authenticatedUser == null) { throw new Error( 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' ); } + + if (global && !this.options.manageGlobalKnowledgeBaseAIAssistant) { + throw new Error('User lacks privileges to create global knowledge base entries'); + } + this.options.logger.debug( () => `Creating Knowledge Base Entry:\n ${JSON.stringify(knowledgeBaseEntry, null, 2)}` ); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index 4cde64424ed7e..bfdf8b96f44b0 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -392,6 +392,7 @@ export class AIAssistantService { setIsKBSetupInProgress: this.setIsKBSetupInProgress.bind(this), spaceId: opts.spaceId, v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled ?? false, + manageGlobalKnowledgeBaseAIAssistant: opts.manageGlobalKnowledgeBaseAIAssistant ?? false, }); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts index 51e3d48505ec2..96753bdd690bd 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts @@ -66,7 +66,6 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout logger.debug(() => `Creating KB Entry:\n${JSON.stringify(request.body)}`); const createResponse = await kbDataClient?.createKnowledgeBaseEntry({ knowledgeBaseEntry: request.body, - // TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty global: request.body.users != null && request.body.users.length === 0, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts index 3a5b8f220eff4..eeb1a5564d1cf 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -50,7 +50,7 @@ export class RequestContextFactory implements IRequestContextFactory { const { options } = this; const { core } = options; - const [, startPlugins] = await core.getStartServices(); + const [coreStart, startPlugins] = await core.getStartServices(); const coreContext = await context.core; const getSpaceId = (): string => @@ -88,14 +88,24 @@ export class RequestContextFactory implements IRequestContextFactory { // Additionally, modelIdOverride is used here to enable setting up the KB using a different ELSER model, which // is necessary for testing purposes (`pt_tiny_elser`). getAIAssistantKnowledgeBaseDataClient: memoize( - ({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => { + async ({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => { const currentUser = getCurrentUser(); + + const { securitySolutionAssistant } = await coreStart.capabilities.resolveCapabilities( + request, + { + capabilityPath: 'securitySolutionAssistant.*', + } + ); + return this.assistantService.createAIAssistantKnowledgeBaseDataClient({ spaceId: getSpaceId(), logger: this.logger, currentUser, modelIdOverride, v2KnowledgeBaseEnabled, + manageGlobalKnowledgeBaseAIAssistant: + securitySolutionAssistant.manageGlobalKnowledgeBaseAIAssistant as boolean, }); } ), diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 075da90b44a0f..e48a9794b7e5c 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -71,7 +71,8 @@ "osquery", "savedObjectsTaggingOss", "guidedOnboarding", - "integrationAssistant" + "integrationAssistant", + "serverless" ], "requiredBundles": [ "esUiShared", @@ -87,4 +88,4 @@ "common" ] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/security_solution/public/assistant/overlay.tsx b/x-pack/plugins/security_solution/public/assistant/overlay.tsx index 145f18875d275..6f0da894dd728 100644 --- a/x-pack/plugins/security_solution/public/assistant/overlay.tsx +++ b/x-pack/plugins/security_solution/public/assistant/overlay.tsx @@ -9,31 +9,13 @@ import { AssistantOverlay as ElasticAssistantOverlay, useAssistantContext, } from '@kbn/elastic-assistant'; -import { useQuery } from '@tanstack/react-query'; -import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context'; -import { useKibana } from '../common/lib/kibana'; export const AssistantOverlay: React.FC = () => { - const { services } = useKibana(); - - const { data: currentUserAvatar } = useQuery({ - queryKey: ['currentUserAvatar'], - queryFn: () => - services.security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({ - dataPath: 'avatar', - }), - select: (data) => { - return data.data.avatar; - }, - keepPreviousData: true, - refetchOnWindowFocus: false, - }); - const { assistantAvailability } = useAssistantContext(); if (!assistantAvailability.hasAssistantPrivilege) { return null; } - return <ElasticAssistantOverlay currentUserAvatar={currentUserAvatar} />; + return <ElasticAssistantOverlay />; }; diff --git a/x-pack/plugins/security_solution/public/assistant/provider.tsx b/x-pack/plugins/security_solution/public/assistant/provider.tsx index 93c65bb463584..f4161fccbc1c2 100644 --- a/x-pack/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/plugins/security_solution/public/assistant/provider.tsx @@ -142,6 +142,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) storage, triggersActionsUi: { actionTypeRegistry }, docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, + userProfile, } = useKibana().services; const basePath = useBasePath(); @@ -225,6 +226,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children }) title={ASSISTANT_TITLE} toasts={toasts} currentAppId={currentAppId ?? 'securitySolutionUI'} + userProfileService={userProfile} > {children} </ElasticAssistantProvider> diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx index 65a0ab84d3412..a3c14b9154c3f 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { MemoryRouter } from '@kbn/shared-ux-router'; import { ManagementSettings } from './management_settings'; import type { Conversation } from '@kbn/elastic-assistant'; import { @@ -77,6 +78,12 @@ describe('ManagementSettings', () => { securitySolutionAssistant: { 'ai-assistant': false }, }, }, + chrome: { + docTitle: { + change: jest.fn(), + }, + setBreadcrumbs: jest.fn(), + }, data: { dataViews: { getIndices: jest.fn(), @@ -95,9 +102,11 @@ describe('ManagementSettings', () => { }); return render( - <QueryClientProvider client={queryClient}> - <ManagementSettings /> - </QueryClientProvider> + <MemoryRouter> + <QueryClientProvider client={queryClient}> + <ManagementSettings /> + </QueryClientProvider> + </MemoryRouter> ); }; diff --git a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx index 48d89e02dfc71..d2434e02641ad 100644 --- a/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx +++ b/x-pack/plugins/security_solution/public/assistant/stack_management/management_settings.tsx @@ -5,9 +5,11 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { AssistantSettingsManagement } from '@kbn/elastic-assistant/impl/assistant/settings/assistant_settings_management'; import type { Conversation } from '@kbn/elastic-assistant'; +import { useSearchParams } from 'react-router-dom-v5-compat'; +import { i18n } from '@kbn/i18n'; import { mergeBaseWithPersistedConversations, useAssistantContext, @@ -16,8 +18,9 @@ import { } from '@kbn/elastic-assistant'; import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation'; import type { FetchConversationsResponse } from '@kbn/elastic-assistant/impl/assistant/api'; -import { useQuery } from '@tanstack/react-query'; -import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context'; +import { SECURITY_AI_SETTINGS } from '@kbn/elastic-assistant/impl/assistant/settings/translations'; +import { CONNECTORS_TAB } from '@kbn/elastic-assistant/impl/assistant/settings/const'; +import type { SettingsTabs } from '@kbn/elastic-assistant/impl/assistant/settings/types'; import { useKibana } from '../../common/lib/kibana'; const defaultSelectedConversationId = WELCOME_CONVERSATION_TITLE; @@ -27,7 +30,6 @@ export const ManagementSettings = React.memo(() => { baseConversations, http, assistantAvailability: { isAssistantEnabled }, - setCurrentUserAvatar, } = useAssistantContext(); const { @@ -38,23 +40,10 @@ export const ManagementSettings = React.memo(() => { }, }, data: { dataViews }, - security, + chrome: { docTitle, setBreadcrumbs }, + serverless, } = useKibana().services; - const { data: currentUserAvatar } = useQuery({ - queryKey: ['currentUserAvatar'], - queryFn: () => - security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({ - dataPath: 'avatar', - }), - select: (d) => { - return d.data.avatar; - }, - keepPreviousData: true, - refetchOnWindowFocus: false, - }); - setCurrentUserAvatar(currentUserAvatar); - const onFetchedConversations = useCallback( (conversationsData: FetchConversationsResponse): Record<string, Conversation> => mergeBaseWithPersistedConversations(baseConversations, conversationsData), @@ -75,6 +64,67 @@ export const ManagementSettings = React.memo(() => { [conversations, getDefaultConversation] ); + docTitle.change(SECURITY_AI_SETTINGS); + + const [searchParams] = useSearchParams(); + const currentTab = useMemo( + () => (searchParams.get('tab') as SettingsTabs) ?? CONNECTORS_TAB, + [searchParams] + ); + + const handleTabChange = useCallback( + (tab: string) => { + navigateToApp('management', { + path: `kibana/securityAiAssistantManagement?tab=${tab}`, + }); + }, + [navigateToApp] + ); + + useEffect(() => { + if (serverless) { + serverless.setBreadcrumbs([ + { + text: i18n.translate( + 'xpack.securitySolution.assistant.settings.breadcrumb.serverless.security', + { + defaultMessage: 'AI Assistant for Security Settings', + } + ), + }, + ]); + } else { + setBreadcrumbs([ + { + text: i18n.translate( + 'xpack.securitySolution.assistant.settings.breadcrumb.stackManagement', + { + defaultMessage: 'Stack Management', + } + ), + onClick: (e) => { + e.preventDefault(); + navigateToApp('management'); + }, + }, + { + text: i18n.translate('xpack.securitySolution.assistant.settings.breadcrumb.index', { + defaultMessage: 'AI Assistants', + }), + onClick: (e) => { + e.preventDefault(); + navigateToApp('management', { path: '/kibana/aiAssistantManagementSelection' }); + }, + }, + { + text: i18n.translate('xpack.securitySolution.assistant.settings.breadcrumb.security', { + defaultMessage: 'Security', + }), + }, + ]); + } + }, [navigateToApp, serverless, setBreadcrumbs]); + if (!securityAIAssistantEnabled) { navigateToApp('home'); } @@ -84,6 +134,8 @@ export const ManagementSettings = React.memo(() => { <AssistantSettingsManagement selectedConversation={currentConversation} dataViews={dataViews} + onTabChange={handleTabChange} + currentTab={currentTab} /> ); } diff --git a/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx b/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx index 68b49bb7d28ee..8ad7661abd0bc 100644 --- a/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx @@ -20,6 +20,8 @@ export interface UseAssistantAvailability { hasConnectorsReadPrivilege: boolean; // When true, user has `Edit` privilege for `AnonymizationFields` hasUpdateAIAssistantAnonymization: boolean; + // When true, user has `Edit` privilege for `Global Knowledge Base` + hasManageGlobalKnowledgeBase: boolean; } export const useAssistantAvailability = (): UseAssistantAvailability => { @@ -28,6 +30,8 @@ export const useAssistantAvailability = (): UseAssistantAvailability => { const hasAssistantPrivilege = capabilities[ASSISTANT_FEATURE_ID]?.['ai-assistant'] === true; const hasUpdateAIAssistantAnonymization = capabilities[ASSISTANT_FEATURE_ID]?.updateAIAssistantAnonymization === true; + const hasManageGlobalKnowledgeBase = + capabilities[ASSISTANT_FEATURE_ID]?.manageGlobalKnowledgeBaseAIAssistant === true; // Connectors & Actions capabilities as defined in x-pack/plugins/actions/server/feature.ts // `READ` ui capabilities defined as: { ui: ['show', 'execute'] } @@ -45,5 +49,6 @@ export const useAssistantAvailability = (): UseAssistantAvailability => { hasConnectorsReadPrivilege, isAssistantEnabled: isEnterprise, hasUpdateAIAssistantAnonymization, + hasManageGlobalKnowledgeBase, }; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index 04860ba9c6c71..56cdc325c9646 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -10,6 +10,7 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a import React from 'react'; import type { AssistantAvailability } from '@kbn/elastic-assistant'; import { AssistantProvider } from '@kbn/elastic-assistant'; +import type { UserProfileService } from '@kbn/core/public'; import { BASE_SECURITY_CONVERSATIONS } from '../../assistant/content/conversations'; interface Props { @@ -33,6 +34,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({ hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }; @@ -51,6 +53,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({ navigateToApp={mockNavigateToApp} baseConversations={BASE_SECURITY_CONVERSATIONS} currentAppId={'test'} + userProfileService={jest.fn() as unknown as UserProfileService} > {children} </AssistantProvider> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx index 23c2d2e7b9f6b..2f07909c0f56a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx @@ -18,6 +18,7 @@ import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BASE_SECURITY_CONVERSATIONS } from '../../../../assistant/content/conversations'; +import type { UserProfileService } from '@kbn/core-user-profile-browser'; jest.mock('../../../../common/lib/kibana'); @@ -34,6 +35,7 @@ const mockAssistantAvailability: AssistantAvailability = { hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }; const queryClient = new QueryClient({ @@ -65,6 +67,7 @@ const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => ( navigateToApp={mockNavigationToApp} baseConversations={BASE_SECURITY_CONVERSATIONS} currentAppId={'security'} + userProfileService={jest.fn() as unknown as UserProfileService} > {children} </AssistantProvider> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx index 3cecf2b0acfe5..9ca0d9fd18e7d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_assistant.test.tsx @@ -33,6 +33,7 @@ describe('useAssistant', () => { hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }); jest @@ -51,6 +52,7 @@ describe('useAssistant', () => { hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }); jest @@ -69,6 +71,7 @@ describe('useAssistant', () => { hasConnectorsAllPrivilege: true, hasConnectorsReadPrivilege: true, hasUpdateAIAssistantAnonymization: true, + hasManageGlobalKnowledgeBase: true, isAssistantEnabled: true, }); jest diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index b9dd41a80e668..6ac8b349b74c5 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -60,6 +60,7 @@ import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/publ import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { IntegrationAssistantPluginStart } from '@kbn/integration-assistant-plugin/public'; +import type { ServerlessPluginStart } from '@kbn/serverless/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -154,6 +155,7 @@ export interface StartPlugins { alerting: PluginStartContract; core: CoreStart; integrationAssistant?: IntegrationAssistantPluginStart; + serverless?: ServerlessPluginStart; } export interface StartPluginsDependencies extends StartPlugins { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index ce79bd061548f..5098a75e00cf2 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -228,5 +228,7 @@ "@kbn/core-saved-objects-server-mocks", "@kbn/core-http-router-server-internal", "@kbn/core-security-server-mocks", + "@kbn/serverless", + "@kbn/core-user-profile-browser", ] } diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/management_cards.ts b/x-pack/plugins/security_solution_serverless/public/navigation/management_cards.ts index cb39ae7c661e0..f6f36d6fa7a3f 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/management_cards.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/management_cards.ts @@ -4,12 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { CardNavExtensionDefinition } from '@kbn/management-cards-navigation'; +import { appCategories, type CardNavExtensionDefinition } from '@kbn/management-cards-navigation'; import { getNavigationPropsFromId, SecurityPageName, ExternalPageName, } from '@kbn/security-solution-navigation'; +import { i18n } from '@kbn/i18n'; import type { Services } from '../common/services'; const SecurityManagementCards = new Map<string, CardNavExtensionDefinition['category']>([ @@ -42,6 +43,12 @@ export const enableManagementCardsLanding = (services: Services) => { {} ); + const securityAiAssistantManagement = getSecurityAiAssistantManagementDefinition(services); + + if (securityAiAssistantManagement) { + cardNavDefinitions.securityAiAssistantManagement = securityAiAssistantManagement; + } + management.setupCardsNavigation({ enabled: true, extendCardNavDefinitions: services.serverless.getNavigationCards( @@ -51,3 +58,29 @@ export const enableManagementCardsLanding = (services: Services) => { }); }); }; + +const getSecurityAiAssistantManagementDefinition = (services: Services) => { + const { application } = services; + const aiAssistantIsEnabled = application.capabilities.securitySolutionAssistant?.['ai-assistant']; + + if (aiAssistantIsEnabled) { + return { + category: appCategories.OTHER, + title: i18n.translate( + 'xpack.securitySolutionServerless.securityAiAssistantManagement.app.title', + { + defaultMessage: 'AI assistant for Security settings', + } + ), + description: i18n.translate( + 'xpack.securitySolutionServerless.securityAiAssistantManagement.app.description', + { + defaultMessage: 'Manage your AI assistant for Security settings.', + } + ), + icon: 'sparkles', + }; + } + + return null; +}; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 8c95f39fd6e3e..1ff986829415b 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_all', 'minimal_read', 'update_anonymization', + 'manage_global_knowledge_base', ], securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: [ diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 41fe1e79b7f12..57a166ef4be9d 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -166,6 +166,7 @@ export default function ({ getService }: FtrProviderContext) { 'minimal_all', 'minimal_read', 'update_anonymization', + 'manage_global_knowledge_base', ], securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: [ diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/assistant.ts b/x-pack/test/security_solution_cypress/cypress/tasks/assistant.ts index 81491abd85f81..5f030c61de65a 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/assistant.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/assistant.ts @@ -42,7 +42,6 @@ import { QUICK_PROMPT_BADGE, ADD_NEW_CONNECTOR, SHOW_ANONYMIZED_BUTTON, - ASSISTANT_SETTINGS_BUTTON, SEND_TO_TIMELINE_BUTTON, } from '../screens/ai_assistant'; import { TOASTER } from '../screens/alerts_detection_rules'; @@ -224,5 +223,4 @@ export const assertConversationReadOnly = () => { cy.get(CHAT_CONTEXT_MENU).should('be.disabled'); cy.get(FLYOUT_NAV_TOGGLE).should('be.disabled'); cy.get(NEW_CHAT).should('be.disabled'); - cy.get(ASSISTANT_SETTINGS_BUTTON).should('be.disabled'); };