From f775a6a3f16758d22f8d94a006f0d155f66839ac Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Tue, 25 Jun 2024 19:42:13 -0400 Subject: [PATCH] [Security Solution] [Attack discovery] Improves Attack discovery code coverage (#186679) ## [Security Solution] [Attack discovery] Improves Attack discovery code coverage ### Summary This PR improves unit test coverage for the [Attack discovery](https://github.com/elastic/kibana/pull/181818) feature. ### Desk testing Run `node scripts/jest --watch x-pack/plugins/security_solution/public/attack_discovery --coverage` --- .../__mocks__/raw_attack_discoveries.ts | 24 + .../routes/attack_discovery/helpers.test.ts | 723 ++++++++++-------- .../attack_chain/axis_tick/index.test.tsx | 29 + .../attack/attack_chain/index.test.tsx | 30 + .../attack/attack_chain/tactic/index.test.tsx | 43 ++ .../attack/mini_attack_chain/index.test.tsx | 28 + .../helpers.test.ts | 30 + .../index.test.tsx | 102 +++ .../get_host_flyout_panel_props.test.ts | 44 ++ .../get_user_flyout_panel_props.test.ts | 26 + .../field_markdown_renderer/helpers.test.ts | 58 ++ .../field_markdown_renderer/index.test.tsx | 110 +++ .../index.test.tsx | 63 ++ .../actionable_summary/index.test.tsx | 109 +++ .../actions_placeholder/index.test.tsx | 23 + .../actions/alerts_badge/index.test.tsx | 21 + .../actions/index.test.tsx | 46 ++ .../actions/take_action/helpers.test.ts | 43 ++ .../actions/take_action/index.test.tsx | 47 ++ .../actions/use_add_to_case/index.test.tsx | 87 +++ .../use_add_to_existing_case/index.test.tsx | 142 ++++ .../attack_discovery_panel/index.test.tsx | 63 ++ .../tabs/alerts_tab/index.test.tsx | 27 + .../tabs/attack_discovery_tab/index.test.tsx | 139 ++++ .../tabs/attack_discovery_tab/index.tsx | 2 +- .../tabs/get_tabs.test.tsx | 63 ++ .../attack_discovery_panel/tabs/get_tabs.tsx | 2 +- .../tabs/index.test.tsx | 38 + .../title/index.test.tsx | 36 + .../view_in_ai_assistant/index.test.tsx | 66 ++ .../use_view_in_ai_assistant.test.ts | 86 +++ .../get_attack_discovery_markdown.test.tsx | 188 +++++ .../public/attack_discovery/helpers.test.tsx | 96 +++ .../public/attack_discovery/helpers.ts | 2 +- .../mock/mock_attack_discovery.ts | 37 + .../mock/mock_use_attack_discovery.ts | 84 ++ .../animated_counter/index.test.tsx | 25 + .../empty_prompt/animated_counter/index.tsx | 4 +- .../pages/empty_prompt/index.test.tsx | 150 ++++ .../pages/header/index.test.tsx | 183 +++++ .../loading_callout/countdown/index.test.tsx | 84 ++ .../generation_timing/index.test.tsx | 40 + .../last_times_popover/helpers.test.ts | 74 ++ .../last_times_popover/index.test.tsx | 59 ++ .../pages/loading_callout/index.test.tsx | 73 ++ .../info_popover_body/index.test.tsx | 55 ++ .../loading_messages/index.test.tsx | 43 ++ .../pages/page_title/index.test.tsx | 22 + .../pages/summary/index.test.tsx | 56 ++ .../pages/summary_count/index.test.tsx | 51 ++ .../pages/upgrade/index.test.tsx | 63 ++ .../use_attack_discovery/helpers.test.ts | 284 +++++++ .../get_anonymized_alerts.test.ts | 171 +++++ .../attack_discovery/get_anonymized_alerts.ts | 2 +- .../get_attack_discovery_prompt.test.ts | 30 + .../get_output_parser.test.ts | 31 + .../tools/attack_discovery/helpers.ts | 21 - 57 files changed, 3945 insertions(+), 333 deletions(-) create mode 100644 x-pack/plugins/elastic_assistant/server/__mocks__/raw_attack_discoveries.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/generation_timing/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/info_popover_body/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/page_title/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/upgrade/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts create mode 100644 x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts create 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/helpers.ts diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/raw_attack_discoveries.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/raw_attack_discoveries.ts new file mode 100644 index 00000000000000..1c43f112da2bb3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/raw_attack_discoveries.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. + */ + +/** + * A mock response from invoking the `attack-discovery` tool. + * This is a JSON string that represents the response from the tool + */ +export const getRawAttackDiscoveriesMock = () => + '{\n "alertsContextCount": 20,\n "attackDiscoveries": [\n {\n "alertIds": [\n "9bb601522d0c0b83783488a27a3ede5bd6a788f4f1ceef07cc8f12ac55f27563",\n "b9d6df8ab34e36c6868c097ff28dd01075df85a5ac1f084ef569ee8c6a4cf660",\n "014b433c3436ef5325cadacc35b6cb2ba8932a9c2ea0ba26d899f95c6fb61395",\n "28017987e64abb6ac486f1410f977d97ebd3a7172189cfdf943a48a59b968066"\n ],\n "detailsMarkdown": "- {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} (macOS {{ host.os.version 13.4 }}) executed a suspicious process {{ process.name unix1 }} with command line {{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }}\\\\n- The process was spawned by another suspicious process {{ process.parent.name My Go Application.app }} with command line {{ 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 }}\\\\n- The parent process was launched by the system process {{ process.parent.parent.name launchd }}\\\\n- Both the child and parent processes had untrusted code signatures\\\\n- The child process attempted to access the user\'s login keychain, potentially indicating credential theft",\n "entitySummaryMarkdown": "Suspicious activity on {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} by {{ user.name 3c8c81bd-0e52-4ce7-a836-48e718dfb6e4 }}",\n "mitreAttackTactics": [\n "Credential Access",\n "Defense Evasion",\n "Execution"\n ],\n "summaryMarkdown": "Suspicious activity detected on a macOS host involving a potentially malicious process attempting to access user credentials. The process was spawned by another untrusted process launched by the system, indicating a multi-stage attack potentially involving credential theft and defense evasion techniques.",\n "title": "Potential Credential Theft on macOS Host"\n },\n {\n "alertIds": [\n "64bcd8a322e6e6aebaee252982d0249cc96bdd75023ea05f58c228a7417c0dfc"\n ],\n "detailsMarkdown": "- {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} (macOS {{ host.os.version 13.4 }}) executed the system utility {{ process.name osascript }} with command line {{ 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 ¬ }}\\\\n- This appears to be an attempt to phish for user credentials by displaying a fake system dialog\\\\n- The osascript process was spawned by the suspicious process {{ process.parent.name My Go Application.app }} with untrusted code signature",\n "entitySummaryMarkdown": "Potential credential phishing attempt on {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} targeting {{ user.name 3c8c81bd-0e52-4ce7-a836-48e718dfb6e4 }}",\n "mitreAttackTactics": [\n "Credential Access",\n "Initial Access",\n "Execution"\n ],\n "summaryMarkdown": "A credential phishing attempt was detected on a macOS host, likely initiated by a malicious process. The attack used osascript to display a fake system dialog prompting the user to enter their password.",\n "title": "Credential Phishing Attempt on macOS"\n },\n {\n "alertIds": [\n "245b60b908ddd84cad06671e273aa7be50699abd27e59423be4415f38c4aeb99",\n "616ac711e967e07a9b725e66aa93321eabf29e4b51f9598a4a11f21ab7ed0f12",\n "035c0295b1c64fd2ebba1b751a3565fd6759942247e9df6e1496c5e332d51840"\n ],\n "detailsMarkdown": "- {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} (macOS {{ host.os.version 13.4 }}) executed a suspicious process {{ process.name My Go Application.app }} with command line {{ process.command_line xpcproxy application.Appify by Machine Box.My Go Application.20.23 }}\\\\n- This process had an untrusted code signature and was launched by the system process {{ process.parent.name launchd }}\\\\n- It appears to have spawned the process {{ process.name unix1 }} in an attempt to obfuscate its activities\\\\n- The unix1 process attempted to make itself executable by running {{ process.name chmod }} with arguments {{ process.command_line chmod 777 /Users/james/unix1 }}",\n "entitySummaryMarkdown": "Suspicious activity involving process obfuscation on {{ host.name cb186c4a-3d70-4878-8ffe-18d84b5df86f }} by {{ user.name fec12d87-2476-4b82-a50d-0829f3815a42 }}",\n "mitreAttackTactics": [\n "Defense Evasion",\n "Execution"\n ],\n "summaryMarkdown": "A suspicious process was detected on a macOS host that appeared to be attempting to obfuscate its activities by spawning other processes and making them executable. The initial process had an untrusted code signature, indicating potentially malicious intent.",\n "title": "Process Obfuscation on macOS Host"\n },\n {\n "alertIds": [\n "54901fb5b0ed88f0c8d737613868a3d62ebc541d31b757349bbe7999d868ce48"\n ],\n "detailsMarkdown": "- {{ host.name 23166d28-d6da-4801-b701-d21ce1a489e5 }} (Windows {{ host.os.version 21H2 (10.0.20348.1607) }}) created a suspicious script file {{ file.path C:\\\\ProgramData\\\\WindowsAppPool\\\\AppPool.vbs }}\\\\n- The file was created by a Microsoft Word process ({{ process.name WINWORD.EXE }}) with trusted code signature\\\\n- This may indicate an attempt to establish persistence or command-and-control through scripting",\n "entitySummaryMarkdown": "Suspicious script file created on {{ host.name 23166d28-d6da-4801-b701-d21ce1a489e5 }} by {{ user.name 45bec1b8-eb98-4ddc-aafb-e3f7e02236dc }}",\n "mitreAttackTactics": [\n "Command and Control",\n "Execution"\n ],\n "summaryMarkdown": "A suspicious VBScript file was created on a Windows host, potentially by an compromised Microsoft Word process. This may be an attempt to establish persistence or command-and-control capabilities through scripting.",\n "title": "Suspicious Script File Creation on Windows"\n },\n {\n "alertIds": [\n "7fe0025f2d2b0d32f04b0e533466666967a21a98adae7499cb05add3355b48fc",\n "3875cbad10604636b892d15f7ff753a02a37d3e4bbe91a39a0fcf72f89101e31",\n "bb2767ebef06a5dc2511e2b865f5ed012dfdf20081bc33cab5c9f20b99e01d8f",\n "76d99c72442819a019dfbf3936cda9a6c5713d84a9ae685b2c4e0bb55e5b9862",\n "0f985965cb3d3b14007873290b9fc8f26f1b6ca0945499dfb693787ea6569265"\n ],\n "detailsMarkdown": "- {{ host.name 9a0ea998-7ce5-4dbb-a690-9856eca617ac }} (Windows {{ host.os.version 21H2 (10.0.20348.1607) }}) executed a suspicious PowerShell script {{ process.command_line \\"C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe\\" -exec bypass -file C:\\\\ProgramData\\\\WindowsAppPool\\\\AppPool.ps1 }}\\\\n- The script was launched by the wscript process, which was spawned by a Microsoft Word process ({{ process.parent.name WINWORD.EXE }})\\\\n- The Word process also created a scheduled task to periodically execute the script\\\\n- The PowerShell script appears to be obfuscated, potentially to hide malicious activities\\\\n- This chain of events indicates a multi-stage attack potentially initiated by a malicious Office document",\n "entitySummaryMarkdown": "Suspicious PowerShell activity on {{ host.name 9a0ea998-7ce5-4dbb-a690-9856eca617ac }} by {{ user.name 45bec1b8-eb98-4ddc-aafb-e3f7e02236dc }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "A multi-stage attack was detected on a Windows host, potentially initiated by a malicious Microsoft Office document. The attack involved creating a scheduled task to execute an obfuscated PowerShell script, likely to hide malicious activities. This indicates techniques for initial access, execution, and defense evasion.",\n "title": "Multi-Stage Attack on Windows Host"\n },\n {\n "alertIds": [\n "a0c49fb228eca1685bd41df0ab66ca1977140de7916663e7a0918087220dd402",\n "a252ca3096831e3eeab07ab70e9269f98b5a66617b44d709425898813326ca63",\n "0ff7d411ca25a5b851e43562c9c660062624498f908ff4b63590d4b5304682af",\n "4d612c721e432598a5b7ea7bbeb2aaa2944c0a35e263d9984297b5416530c88f"\n ],\n "detailsMarkdown": "- {{ host.name 634eb7d8-0ce0-4591-b5f5-fb65803b89d8 }} (Windows {{ host.os.version 21H2 (10.0.20348.1607) }}) executed a suspicious PowerShell script {{ process.command_line \\"C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe\\" -ep bypass -file \\"C:\\\\Users\\\\ADMINI~1\\\\AppData\\\\Local\\\\Temp\\\\2\\\\Package Installation Dir\\\\chch.ps1\\" }}\\\\n- The script was launched by the msiexec.exe process, which may indicate an attempt to use a trusted Windows utility for defense evasion\\\\n- Elastic Endpoint detected the Bb malware family in the PowerShell process memory\\\\n- The PowerShell process also made network connections, potentially for command-and-control or data exfiltration",\n "entitySummaryMarkdown": "Malware detected on {{ host.name 634eb7d8-0ce0-4591-b5f5-fb65803b89d8 }} targeting {{ user.name 45bec1b8-eb98-4ddc-aafb-e3f7e02236dc }}",\n "mitreAttackTactics": [\n "Defense Evasion",\n "Execution"\n ],\n "summaryMarkdown": "The B malware was detected on a Windows host, executed through a PowerShell script launched by the msiexec.exe process. This appears to be an attempt to use a trusted Windows utility for defense evasion. The malware process also made network connections, potentially for command-and-control or data exfiltration.",\n "title": "Bb Malware Execution on Windows"\n },\n {\n "alertIds": [\n "764c0944288db1704f7a0fff2db7fe19e8285fa4272dec828ae4186ba0dfd3b3",\n "85672064aeb762a1121139a6d98fd3c5f6be8f18b49e4504c3f5e5a36679afe7"\n ],\n "detailsMarkdown": "- {{ host.name d813c7ba-6141-4292-8f40-c800c27645a4 }} (Linux {{ host.os.version 22.04.1 }}) executed a suspicious process {{ process.command_line sh -c /bin/rm -f /dev/shm/kdmtmpflush;/bin/cp ./74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 /dev/shm/kdmtmpflush && /bin/chmod 755 /dev/shm/kdmtmpflush && /dev/shm/kdmtmpflush --init && /bin/rm -f /dev/shm/kdmtmpflush }}\\\\n- This copied a file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} to /dev/shm/kdmtmpflush, made it executable, and executed it\\\\n- Elastic Endpoint detected the Door malware family associated with this file",\n "entitySummaryMarkdown": "Malware executed on {{ host.name d813c7ba-6141-4292-8f40-c800c27645a4 }} by {{ user.name fec12d87-2476-4b82-a50d-0829f3815a42 }}",\n "mitreAttackTactics": [\n "Execution"\n ],\n "summaryMarkdown": "The Door malware was executed on a Linux host by copying an untrusted file to a temporary path, making it executable, and running it. This indicates malicious code execution on the compromised system.",\n "title": "Door Malware Execution on Linux"\n }\n ]\n}'; + +export const getRawAttackDiscoveriesReplacementsMock = () => ({ + '3c8c81bd-0e52-4ce7-a836-48e718dfb6e4': 'james', + 'cb186c4a-3d70-4878-8ffe-18d84b5df86f': 'SRVMAC08', + 'fec12d87-2476-4b82-a50d-0829f3815a42': 'root', + '45bec1b8-eb98-4ddc-aafb-e3f7e02236dc': 'Administrator', + '23166d28-d6da-4801-b701-d21ce1a489e5': 'SRVWIN07-PRIV', + '9a0ea998-7ce5-4dbb-a690-9856eca617ac': 'SRVWIN07', + '634eb7d8-0ce0-4591-b5f5-fb65803b89d8': 'SRVWIN06', + 'd813c7ba-6141-4292-8f40-c800c27645a4': 'SRVNIX05', +}); 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 index 086af015d76e25..7f4baec88e60e3 100644 --- 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 @@ -4,6 +4,7 @@ * 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 { @@ -30,6 +31,12 @@ import { 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))), @@ -61,6 +68,7 @@ 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', @@ -69,13 +77,25 @@ const mockAuthenticatedUser = { name: 'my_realm_name', }, } as AuthenticatedUser; + const mockApiConfig = { connectorId: 'connector-id', actionTypeId: '.bedrock', model: 'model', provider: OpenAiProviderType.OpenAi, }; + const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; + +const mockActions: ActionsPluginStart = {} as ActionsPluginStart; +// 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(() => { @@ -92,6 +112,22 @@ describe('helpers', () => { updateAttackDiscovery.mockResolvedValue({}); }); describe('getAssistantToolParams', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const esClient = elasticsearchClientMock.createElasticsearchClient(); + const langChainTimeout = 1000; + const latestReplacements = {}; + const llm = new ActionsClientLlm({ + actions: mockActions, + connectorId: 'test-connecter-id', + llmType: 'bedrock', + logger: mockLogger, + request: mockRequest, + temperature: 0, + timeout: 580000, + }); + const onNewReplacements = jest.fn(); + const size = 20; + const mockParams = { actions: {} as unknown as ActionsPluginStart, alertsIndexPattern: 'alerts-*', @@ -127,364 +163,439 @@ describe('helpers', () => { ...REQUIRED_FOR_ATTACK_DISCOVERY, ]); }); - }); - - 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, + it('returns the expected AssistantToolParams when anonymizationFields are provided', () => { + const anonymizationFields = [ + getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()), + ]; + + const result = getAssistantToolParams({ + actions: mockParams.actions, + alertsIndexPattern, + apiConfig: mockApiConfig, + anonymizationFields, + connectorTimeout: 1000, + latestReplacements, + esClient, + langChainTimeout, + logger: mockLogger, + onNewReplacements, + request: mockRequest, + size, }); - 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({ + alertsIndexPattern, + anonymizationFields: [...anonymizationFields, ...REQUIRED_FOR_ATTACK_DISCOVERY], + isEnabledKnowledgeBase: false, + chain: undefined, + esClient, + langChainTimeout, + llm, + logger: mockLogger, + modelExists: false, + onNewReplacements, + replacements: latestReplacements, + request: mockRequest, + size, }); - 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); + it('returns the expected AssistantToolParams when anonymizationFields is undefined', () => { + const anonymizationFields = undefined; + + const result = getAssistantToolParams({ + actions: mockParams.actions, + alertsIndexPattern, + apiConfig: mockApiConfig, + anonymizationFields, + connectorTimeout: 1000, + latestReplacements, + esClient, + langChainTimeout, + logger: mockLogger, + onNewReplacements, + request: mockRequest, + size, + }); - await expect( - updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig) - ).rejects.toThrow('Could not create attack discovery for connectorId: connector-id'); + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: [...REQUIRED_FOR_ATTACK_DISCOVERY], + isEnabledKnowledgeBase: false, + chain: undefined, + esClient, + langChainTimeout, + llm, + logger: mockLogger, + modelExists: false, + onNewReplacements, + replacements: latestReplacements, + request: mockRequest, + size, + }); }); - }); - - 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, + 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); }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: expect.objectContaining({ - status: attackDiscoveryStatus.canceled, - }), - authenticatedUser: mockAuthenticatedUser, + + 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]); }); - 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( + 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.connectorId - ) - ).rejects.toThrow( - 'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.' - ); - }); + 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); - it('should throw an error if attack discovery does not exist', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - await expect( - updateAttackDiscoveryStatusToCanceled( + const result = await updateAttackDiscoveryStatusToRunning( mockDataClient, mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow('Could not find attack discovery for connector id: connector-id'); + 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'); + }); }); - it('should throw error if updateAttackDiscovery returns null', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(null); - await expect( - updateAttackDiscoveryStatusToCanceled( + 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 - ) - ).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(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ + connectorId: mockApiConfig.connectorId, + authenticatedUser: mockAuthenticatedUser, + }); + expect(updateAttackDiscovery).toHaveBeenCalledWith({ + attackDiscoveryUpdateProps: expect.objectContaining({ + status: attackDiscoveryStatus.canceled, + }), + authenticatedUser: mockAuthenticatedUser, + }); + expect(result).toEqual(existingAd); + }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - alertsContextCount: 5, - attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], + it('should throw an error if attack discovery is not running', async () => { + findAttackDiscoveryByConnectorId.mockResolvedValue({ + ...existingAd, status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - generationIntervals: [{ date, durationMs: 120000 }, ...mockCurrentAd.generationIntervals], - }, - authenticatedUser: mockAuthenticatedUser, + }); + 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.' + ); }); - 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 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'); }); }); - it('should update attack discoveries without generation interval if no discoveries are found', async () => { - const noDiscoveriesRaw = JSON.stringify({ - alertsContextCount: 0, - attackDiscoveries: [], + 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'); - await updateAttackDiscoveries({ - ...mockArgs, - rawAttackDiscoveries: noDiscoveriesRaw, + 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, + }); }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { + it('should update attack discoveries without generation interval if no discoveries are found', async () => { + const noDiscoveriesRaw = JSON.stringify({ alertsContextCount: 0, attackDiscoveries: [], - status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - }, - authenticatedUser: mockAuthenticatedUser, + }); + + 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, + }); }); - 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 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); - it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { - getAttackDiscovery.mockResolvedValue({ - ...mockCurrentAd, - status: attackDiscoveryStatus.canceled, + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(updateAttackDiscovery).not.toHaveBeenCalled(); }); - 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, + 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, + 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, + 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, + }); }); - 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); + 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(); - }); + 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, + 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, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx new file mode 100644 index 00000000000000..4dcd772d783bda --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx @@ -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 { render } from '@testing-library/react'; +import React from 'react'; + +import { AxisTick } from '.'; + +describe('AxisTick', () => { + it('renders the top cell', async () => { + const { getByTestId } = render(); + + const topCell = getByTestId('topCell'); + + expect(topCell).toBeInTheDocument(); + }); + + it('renders the bottom cell', async () => { + const { getByTestId } = render(); + + const bottomCell = getByTestId('bottomCell'); + + expect(bottomCell).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx new file mode 100644 index 00000000000000..195a5fe49dd198 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { getTacticMetadata } from '../../helpers'; +import { AttackChain } from '.'; + +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; + +describe('AttackChain', () => { + it('renders the expected tactics', () => { + // get detected tactics from the attack discovery: + const tacticMetadata = getTacticMetadata(mockAttackDiscovery).filter( + (tactic) => tactic.detected + ); + expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition + + render(); + + tacticMetadata?.forEach((tactic) => { + expect(screen.getByText(tactic.name)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx new file mode 100644 index 00000000000000..9c4166a43d6202 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Tactic } from '.'; + +describe('Tactic', () => { + const tactic = 'Privilege Escalation'; + + it('renders the tactic name', () => { + render(); + + const tacticText = screen.getByTestId('tacticText'); + + expect(tacticText).toHaveTextContent(tactic); + }); + + const detectedTestCases: boolean[] = [true, false]; + + detectedTestCases.forEach((detected) => { + it(`renders the inner circle when detected is ${detected}`, () => { + render(); + + const innerCircle = screen.getByTestId('innerCircle'); + + expect(innerCircle).toBeInTheDocument(); + }); + + it(`renders the outerCircle circle when detected is ${detected}`, () => { + render(); + + const outerCircle = screen.getByTestId('outerCircle'); + + expect(outerCircle).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx new file mode 100644 index 00000000000000..c9923754d25dad --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import type { TacticMetadata } from '../../helpers'; +import { getTacticMetadata } from '../../helpers'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { MiniAttackChain } from '.'; + +describe('MiniAttackChain', () => { + it('displays the expected number of circles', () => { + // get detected tactics from the attack discovery: + const tacticMetadata: TacticMetadata[] = getTacticMetadata(mockAttackDiscovery); + expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition + + render(); + + const circles = screen.getAllByTestId('circle'); + + expect(circles.length).toEqual(tacticMetadata.length); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts new file mode 100644 index 00000000000000..fd3ada8f6bdd9e --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.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 { getIconFromFieldName } from './helpers'; + +describe('helpers', () => { + describe('getIconFromFieldName', () => { + it('returns the expected icon for a known field name', () => { + const fieldName = 'host.name'; + const expectedIcon = 'desktop'; + + const icon = getIconFromFieldName(fieldName); + + expect(icon).toEqual(expectedIcon); + }); + + it('returns an empty string for an unknown field name', () => { + const fieldName = 'unknown.field'; + const emptyIcon = ''; + + const icon = getIconFromFieldName(fieldName); + + expect(icon).toEqual(emptyIcon); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx new file mode 100644 index 00000000000000..5772272673b67c --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + EuiMarkdownFormat, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../common/mock'; +import { getFieldMarkdownRenderer } from '../field_markdown_renderer'; +import { AttackDiscoveryMarkdownParser } from '.'; + +describe('AttackDiscoveryMarkdownParser', () => { + it('parsees markdown with valid fields', () => { + const attackDiscoveryParsingPluginList = [ + ...getDefaultEuiMarkdownParsingPlugins(), + AttackDiscoveryMarkdownParser, + ]; + + const markdownWithValidFields = ` + The following attack chain was detected involving Microsoft Office documents on multiple hosts: + +- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name 2c13d131-8fab-41b9-841e-669c66315a23 }}. +- This document launched a child process to write and execute a malicious script file named "AppPool.vbs". +- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". +- On {{ host.name 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. + +This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. + `; + + const processingPluginList = getDefaultEuiMarkdownProcessingPlugins(); + processingPluginList[1][1].components.fieldPlugin = getFieldMarkdownRenderer(false); + + render( + + + {markdownWithValidFields} + + + ); + + const result = screen.getByTestId('attackDiscoveryMarkdownFormatter'); + + expect(result).toHaveTextContent( + 'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by 2c13d131-8fab-41b9-841e-669c66315a23. This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On 5149b291-72d0-4373-93ec-c117477932fe, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.' + ); + }); + + it('parsees markdown with invalid fields', () => { + const attackDiscoveryParsingPluginList = [ + ...getDefaultEuiMarkdownParsingPlugins(), + AttackDiscoveryMarkdownParser, + ]; + + const markdownWithInvalidFields = ` + The following attack chain was detected involving Microsoft Office documents on multiple hosts: + +- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name }}. +- This document launched a child process to write and execute a malicious script file named "AppPool.vbs". +- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". +- On {{ 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. + +This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. {{ foo.bar baz }} + `; + + const processingPluginList = getDefaultEuiMarkdownProcessingPlugins(); + processingPluginList[1][1].components.fieldPlugin = getFieldMarkdownRenderer(false); + + render( + + + {markdownWithInvalidFields} + + + ); + + const result = screen.getByTestId('attackDiscoveryMarkdownFormatter'); + + expect(result).toHaveTextContent( + 'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by . This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On (Empty string), a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. baz' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts new file mode 100644 index 00000000000000..ea42a7ec1b045a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.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 { getHostFlyoutPanelProps, isHostName } from './get_host_flyout_panel_props'; + +describe('getHostFlyoutPanelProps', () => { + describe('isHostName', () => { + it('returns true for "host.name"', () => { + const result = isHostName('host.name'); + + expect(result).toBe(true); + }); + + it('returns true for "host.hostname"', () => { + const result = isHostName('host.hostname'); + + expect(result).toBe(true); + }); + + it('returns false for other field names', () => { + const result = isHostName('some.other.field'); + + expect(result).toBe(false); + }); + }); + + describe('getHostFlyoutPanelProps', () => { + it('returns the correct FlyoutPanelProps', () => { + const contextId = 'contextId'; + const hostName = 'foo'; + + const result = getHostFlyoutPanelProps({ contextId, hostName }); + + expect(result).toEqual({ + id: 'host-panel', + params: { contextID: contextId, hostName, scopeId: 'alerts-page' }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts new file mode 100644 index 00000000000000..92cb21e1f5dee4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { isUserName } from './get_user_flyout_panel_props'; + +describe('getUserFlyoutPanelProps', () => { + describe('isUserName', () => { + it('returns true when fieldName is "user.name"', () => { + const fieldName = 'user.name'; + const result = isUserName(fieldName); + + expect(result).toBe(true); + }); + + it('returns false when fieldName is NOT "user.name"', () => { + const fieldName = 'other.field'; + const result = isUserName(fieldName); + + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts new file mode 100644 index 00000000000000..e6e001d290afe7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { getFlyoutPanelProps } from './helpers'; + +describe('helpers', () => { + describe('getFlyoutPanelProps', () => { + it('returns FlyoutPanelProps for a valid host name', () => { + const contextId = 'contextId'; + const fieldName = 'host.name'; + const value = 'example.com'; + + const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value }); + + expect(flyoutPanelProps).toEqual({ + id: 'host-panel', + params: { contextID: contextId, hostName: value, scopeId: 'alerts-page' }, + }); + }); + + it('returns FlyoutPanelProps for a valid user name', () => { + const contextId = 'contextId'; + const fieldName = 'user.name'; + const value = 'administator'; + + const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value }); + + expect(flyoutPanelProps).toEqual({ + id: 'user-panel', + params: { contextID: contextId, userName: value, scopeId: 'alerts-page' }, + }); + }); + + it('returns null for an unknown field name', () => { + const contextId = 'contextId'; + const fieldName = 'unknown.field'; + const value = 'example'; + + const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value }); + + expect(flyoutPanelProps).toBeNull(); + }); + + it('returns null when value is not a string', () => { + const contextId = 'contextId'; + const fieldName = 'host.name'; + const value = 123; + + const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value }); + + expect(flyoutPanelProps).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx new file mode 100644 index 00000000000000..8f647d02a626f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../common/mock'; +import { getFieldMarkdownRenderer } from '.'; + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), +})); + +describe('getFieldMarkdownRenderer', () => { + const mockOpenRightPanel = jest.fn(); + const mockUseExpandableFlyoutApi = useExpandableFlyoutApi as jest.MockedFunction< + typeof useExpandableFlyoutApi + >; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseExpandableFlyoutApi.mockReturnValue({ + closeFlyout: jest.fn(), + closeLeftPanel: jest.fn(), + closePreviewPanel: jest.fn(), + closeRightPanel: jest.fn(), + previousPreviewPanel: jest.fn(), + openFlyout: jest.fn(), + openLeftPanel: jest.fn(), + openPreviewPanel: jest.fn(), + openRightPanel: mockOpenRightPanel, + }); + }); + + it('renders the field value', () => { + const FieldMarkdownRenderer = getFieldMarkdownRenderer(false); + const icon = ''; + const name = 'some.field'; + const value = 'some.value'; + + render( + + + + ); + + const fieldValue = screen.getByText(value); + + expect(fieldValue).toBeInTheDocument(); + }); + + it('opens the right panel when the entity button is clicked', () => { + const FieldMarkdownRenderer = getFieldMarkdownRenderer(false); + const icon = 'user'; + const name = 'user.name'; + const value = 'some.user'; + + render( + + + + ); + + const entityButton = screen.getByTestId('entityButton'); + + fireEvent.click(entityButton); + + expect(mockOpenRightPanel).toHaveBeenCalledTimes(1); + }); + + it('does NOT render the entity button when flyoutPanelProps is null', () => { + const FieldMarkdownRenderer = getFieldMarkdownRenderer(false); + const icon = ''; + const name = 'some.field'; + const value = 'some.value'; + + render( + + + + ); + + const entityButton = screen.queryByTestId('entityButton'); + + expect(entityButton).not.toBeInTheDocument(); + }); + + it('renders disabled actions badge when disableActions is true', () => { + const FieldMarkdownRenderer = getFieldMarkdownRenderer(true); // disable actions + const icon = 'user'; + const name = 'user.name'; + const value = 'some.user'; + + render( + + + + ); + + const disabledActionsBadge = screen.getByTestId('disabledActionsBadge'); + + expect(disabledActionsBadge).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx new file mode 100644 index 00000000000000..5013ce646fe289 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../common/mock'; +import { AttackDiscoveryMarkdownFormatter } from '.'; + +describe('AttackDiscoveryMarkdownFormatter', () => { + const markdown = ` + The following attack chain was detected involving Microsoft Office documents on multiple hosts: + +- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name 2c13d131-8fab-41b9-841e-669c66315a23 }}. +- This document launched a child process to write and execute a malicious script file named "AppPool.vbs". +- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". +- On {{ host.name 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. + +This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. + `; + + it('renders the expected markdown', () => { + render( + + + + ); + + const result = screen.getByTestId('attackDiscoveryMarkdownFormatter'); + + expect(result).toHaveTextContent( + 'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by 2c13d131-8fab-41b9-841e-669c66315a23. This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On 5149b291-72d0-4373-93ec-c117477932fe, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.' + ); + }); + + it('renders interactive host entities', () => { + render( + + + + ); + + const entities = screen.getAllByTestId('entityButton'); + + expect(entities.length).toEqual(3); + }); + + it('renders NON-interactive host entities when disableActions is true', () => { + render( + + + + ); + + const entities = screen.queryAllByTestId('entityButton'); + + expect(entities.length).toEqual(0); // <-- no interactive buttons + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx new file mode 100644 index 00000000000000..55d636bf35270d --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { ActionableSummary } from '.'; +import { TestProviders } from '../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; + +describe('ActionableSummary', () => { + const mockReplacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + '3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username', + }; + + describe('when entities with replacements are provided', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders a hostname with the expected value from replacements', () => { + expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent('foo.hostname'); + }); + + it('renders a username with the expected value from replacements', () => { + expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent('bar.username'); + }); + }); + + describe('when entities that do NOT have replacements are provided', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders a hostname with with the original hostname value', () => { + expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent( + '5e454c38-439c-4096-8478-0a55511c76e3' + ); + }); + + it('renders a username with the original username value', () => { + expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent( + '3bdc7952-a334-4d95-8092-cd176546e18a' + ); + }); + }); + + describe('when showAnonymized is true', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders a disabled badge with the original hostname value', () => { + expect(screen.getAllByTestId('disabledActionsBadge')[0]).toHaveTextContent( + '5e454c38-439c-4096-8478-0a55511c76e3' + ); + }); + + it('renders a disabled badge with the original username value', () => { + expect(screen.getAllByTestId('disabledActionsBadge')[1]).toHaveTextContent( + '3bdc7952-a334-4d95-8092-cd176546e18a' + ); + }); + }); + + describe('View in AI assistant', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders the View in AI assistant button', () => { + expect(screen.getByTestId('viewInAiAssistantCompact')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx new file mode 100644 index 00000000000000..ac2494f050d886 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { ActionsPlaceholder } from '.'; + +describe('ActionsPlaceholder', () => { + beforeEach(() => render()); + + const expectedSkeletonTitles = ['skeletonTitle1', 'skeletonTitle2', 'skeletonTitle3']; + + expectedSkeletonTitles.forEach((expectedSkeletonTitle) => { + it(`renders the ${expectedSkeletonTitle} skeleton title`, () => { + expect(screen.getByTestId(expectedSkeletonTitle)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx new file mode 100644 index 00000000000000..bc45d195aacfa5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx @@ -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 { render } from '@testing-library/react'; +import React from 'react'; + +import { AlertsBadge } from '.'; + +describe('AlertsBadge', () => { + it('render the expected alerts count', () => { + const alertsCount = 5; + + const { getByTestId } = render(); + const badgeElement = getByTestId('alertsBadge'); + + expect(badgeElement.textContent).toBe(`${alertsCount}`); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx new file mode 100644 index 00000000000000..30096f33dde90e --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Actions } from '.'; +import { TestProviders } from '../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { ATTACK_CHAIN, ALERTS } from './translations'; + +describe('Actions', () => { + beforeEach(() => + render( + + + + ) + ); + + it('renders the attack chain label', () => { + expect(screen.getByTestId('attackChainLabel')).toHaveTextContent(ATTACK_CHAIN); + }); + + it('renders the mini attack chain component', () => { + expect(screen.getByTestId('miniAttackChain')).toBeInTheDocument(); + }); + + it('renders the alerts label', () => { + expect(screen.getByTestId('alertsLabel')).toHaveTextContent(ALERTS); + }); + + it('renders the alerts badge with the expected count', () => { + expect(screen.getByTestId('alertsBadge')).toHaveTextContent( + `${mockAttackDiscovery.alertIds.length}` + ); + }); + + it('renders the take action dropdown', () => { + expect(screen.getByTestId('takeAction')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.test.ts new file mode 100644 index 00000000000000..9d58a1487d0ca7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.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 { getOriginalAlertIds } from './helpers'; + +describe('helpers', () => { + describe('getOriginalAlertIds', () => { + const alertIds = ['alert1', 'alert2', 'alert3']; + + it('returns the original alertIds when no replacements are provided', () => { + const result = getOriginalAlertIds({ alertIds }); + + expect(result).toEqual(alertIds); + }); + + it('returns the replaced alertIds when replacements are provided', () => { + const replacements = { + alert1: 'replaced1', + alert3: 'replaced3', + }; + const expected = ['replaced1', 'alert2', 'replaced3']; + + const result = getOriginalAlertIds({ alertIds, replacements }); + + expect(result).toEqual(expected); + }); + + it('returns the original alertIds when replacements are provided but no replacement is found', () => { + const replacements = { + alert4: 'replaced4', + alert5: 'replaced5', + }; + + const result = getOriginalAlertIds({ alertIds, replacements }); + + expect(result).toEqual(alertIds); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx new file mode 100644 index 00000000000000..2772aa6e0c7a2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx @@ -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 { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { TakeAction } from '.'; + +describe('TakeAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + + const takeActionButtons = screen.getAllByTestId('takeActionPopoverButton'); + + fireEvent.click(takeActionButtons[0]); // open the popover + }); + + it('renders the Add to new case action', () => { + const addToCase = screen.getByTestId('addToCase'); + + expect(addToCase).toBeInTheDocument(); + }); + + it('renders the Add to existing case action', () => { + const addToCase = screen.getByTestId('addToExistingCase'); + + expect(addToCase).toBeInTheDocument(); + }); + + it('renders the View in AI Assistant action', () => { + const addToCase = screen.getByTestId('viewInAiAssistant'); + + expect(addToCase).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx new file mode 100644 index 00000000000000..d1c2e84049e9a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/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 { act, renderHook } from '@testing-library/react-hooks'; + +import { useAddToNewCase } from '.'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + cases: { + hooks: { + useCasesAddToNewCaseFlyout: jest.fn().mockReturnValue({ + open: jest.fn(), + }), + }, + }, + }, + }), +})); + +describe('useAddToNewCase', () => { + it('disables the action when a user can NOT create and read cases', () => { + const canUserCreateAndReadCases = jest.fn().mockReturnValue(false); + + const { result } = renderHook( + () => + useAddToNewCase({ + canUserCreateAndReadCases, + title: 'Persistent Execution of Malicious Application', + }), + { + wrapper: TestProviders, + } + ); + + expect(result.current.disabled).toBe(true); + }); + + it('enables the action when a user can create and read cases', () => { + const canUserCreateAndReadCases = jest.fn().mockReturnValue(true); + + const { result } = renderHook( + () => + useAddToNewCase({ + canUserCreateAndReadCases, + title: 'Persistent Execution of Malicious Application', + }), + { + wrapper: TestProviders, + } + ); + + expect(result.current.disabled).toBe(false); + }); + + it('calls the onClick callback when provided', () => { + const onClick = jest.fn(); + const canUserCreateAndReadCases = jest.fn().mockReturnValue(true); + + const { result } = renderHook( + () => + useAddToNewCase({ + canUserCreateAndReadCases, + title: 'Persistent Execution of Malicious Application', + onClick, + }), + { + wrapper: TestProviders, + } + ); + + act(() => { + result.current.onAddToNewCase({ + alertIds: ['alert1', 'alert2'], + markdownComments: ['Comment 1', 'Comment 2'], + }); + }); + + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx new file mode 100644 index 00000000000000..80245d371f412a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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, renderHook } from '@testing-library/react-hooks'; + +import { useAddToExistingCase } from '.'; +import { useKibana } from '../../../../common/lib/kibana'; +import { TestProviders } from '../../../../common/mock'; + +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + cases: { + hooks: { + useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({ + open: jest.fn(), + }), + }, + }, + }, + }), +})); + +describe('useAddToExistingCase', () => { + const mockCanUserCreateAndReadCases = jest.fn(); + const mockOnClick = jest.fn(); + const mockAlertIds = ['alert1', 'alert2']; + const mockMarkdownComments = ['Comment 1', 'Comment 2']; + const mockReplacements = { alert1: 'replacement1', alert2: 'replacement2' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables the action when a user can NOT create and read cases', () => { + mockCanUserCreateAndReadCases.mockReturnValue(false); + + const { result } = renderHook( + () => + useAddToExistingCase({ + canUserCreateAndReadCases: mockCanUserCreateAndReadCases, + onClick: mockOnClick, + }), + { + wrapper: TestProviders, + } + ); + + expect(result.current.disabled).toBe(true); + }); + + it('enables the action when a user can create and read cases', () => { + mockCanUserCreateAndReadCases.mockReturnValue(true); + + const { result } = renderHook( + () => + useAddToExistingCase({ + canUserCreateAndReadCases: mockCanUserCreateAndReadCases, + onClick: mockOnClick, + }), + { + wrapper: TestProviders, + } + ); + + expect(result.current.disabled).toBe(false); + }); + + it('calls the openSelectCaseModal function with the expected attachments', () => { + mockCanUserCreateAndReadCases.mockReturnValue(true); + const mockOpenSelectCaseModal = jest.fn(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: { + hooks: { + useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({ + open: mockOpenSelectCaseModal, + }), + }, + }, + }, + }); + + const { result } = renderHook( + () => + useAddToExistingCase({ + canUserCreateAndReadCases: mockCanUserCreateAndReadCases, + onClick: mockOnClick, + }), + { + wrapper: TestProviders, + } + ); + + act(() => { + result.current.onAddToExistingCase({ + alertIds: mockAlertIds, + markdownComments: mockMarkdownComments, + replacements: mockReplacements, + }); + }); + + expect(mockOpenSelectCaseModal).toHaveBeenCalledWith({ + getAttachments: expect.any(Function), + }); + + const getAttachments = mockOpenSelectCaseModal.mock.calls[0][0].getAttachments; + const attachments = getAttachments(); + + expect(attachments).toHaveLength(4); + expect(attachments[0]).toEqual({ + comment: 'Comment 1', + type: 'user', + }); + expect(attachments[1]).toEqual({ + comment: 'Comment 2', + type: 'user', + }); + expect(attachments[2]).toEqual({ + alertId: 'replacement1', // <-- case attachment uses the replacement values + index: '', + rule: { + id: null, + name: null, + }, + type: 'alert', + }); + expect(attachments[3]).toEqual({ + alertId: 'replacement2', + index: '', + rule: { + id: null, + name: null, + }, + type: 'alert', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx new file mode 100644 index 00000000000000..d65dd87117ca38 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AttackDiscoveryPanel } from '.'; +import { TestProviders } from '../../common/mock'; +import { mockAttackDiscovery } from '../mock/mock_attack_discovery'; + +describe('AttackDiscoveryPanel', () => { + it('renders the attack discovery accordion', () => { + render( + + + + ); + + const attackDiscoveryAccordion = screen.getByTestId('attackDiscoveryAccordion'); + + expect(attackDiscoveryAccordion).toBeInTheDocument(); + }); + + it('renders empty accordion content', () => { + render( + + + + ); + + const emptyAccordionContent = screen.getByTestId('emptyAccordionContent'); + + expect(emptyAccordionContent).toBeInTheDocument(); + }); + + it('renders the attack discovery summary', () => { + render( + + + + ); + + const actionableSummary = screen.getByTestId('actionableSummary'); + + expect(actionableSummary).toBeInTheDocument(); + }); + + it('renders the attack discovery tabs panel when accordion is open', () => { + render( + + + + ); + + const attackDiscoveryTabsPanel = screen.getByTestId('attackDiscoveryTabsPanel'); + + expect(attackDiscoveryTabsPanel).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx new file mode 100644 index 00000000000000..c505aafa6631b7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { AlertsTab } from '.'; + +describe('AlertsTab', () => { + it('renders the alerts tab', () => { + render( + + + + ); + + const alertsTab = screen.getByTestId('alertsTab'); + + expect(alertsTab).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx new file mode 100644 index 00000000000000..3c05a10a6eb06b --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AttackDiscoveryTab } from '.'; +import type { Replacements } from '@kbn/elastic-assistant-common'; +import { TestProviders } from '../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { ATTACK_CHAIN, DETAILS, SUMMARY } from './translations'; + +describe('AttackDiscoveryTab', () => { + const mockReplacements: Replacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + '3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username', + }; + + describe('when showAnonymized is false', () => { + const showAnonymized = false; + + beforeEach(() => + render( + + + + ) + ); + + it('renders the summary using the real host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const summaryMarkdown = markdownFormatters[0]; + + expect(summaryMarkdown).toHaveTextContent( + 'A multi-stage malware attack was detected on foo.hostname involving bar.username. A suspicious application delivered malware, attempted credential theft, and established persistence.' + ); + }); + + it('renders the details using the real host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const detailsMarkdown = markdownFormatters[1]; + + expect(detailsMarkdown).toHaveTextContent( + `The following attack progression appears to have occurred on the host foo.hostname involving the user bar.username: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.` + ); + }); + }); + + describe('when showAnonymized is true', () => { + const showAnonymized = true; + + beforeEach(() => + render( + + + + ) + ); + + it('renders the summary using the anonymized host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const summaryMarkdown = markdownFormatters[0]; + + expect(summaryMarkdown).toHaveTextContent( + 'A multi-stage malware attack was detected on 5e454c38-439c-4096-8478-0a55511c76e3 involving 3bdc7952-a334-4d95-8092-cd176546e18a. A suspicious application delivered malware, attempted credential theft, and established persistence.' + ); + }); + + it('renders the details using the anonymized host and username', () => { + const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter'); + const detailsMarkdown = markdownFormatters[1]; + + expect(detailsMarkdown).toHaveTextContent( + `The following attack progression appears to have occurred on the host 5e454c38-439c-4096-8478-0a55511c76e3 involving the user 3bdc7952-a334-4d95-8092-cd176546e18a: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.` + ); + }); + }); + + describe('common cases', () => { + beforeEach(() => + render( + + + + ) + ); + + it('renders the expected summary title', () => { + const summaryTitle = screen.getByTestId('summaryTitle'); + + expect(summaryTitle).toHaveTextContent(SUMMARY); + }); + + it('renders the expected details title', () => { + const detailsTitle = screen.getByTestId('detailsTitle'); + + expect(detailsTitle).toHaveTextContent(DETAILS); + }); + + it('renders the expected attack chain title', () => { + const attackChainTitle = screen.getByTestId('attackChainTitle'); + + expect(attackChainTitle).toHaveTextContent(ATTACK_CHAIN); + }); + + it('renders the attack chain', () => { + const attackChain = screen.getByTestId('attackChain'); + + expect(attackChain).toBeInTheDocument(); + }); + + it('renders the "View in AI Assistant" button', () => { + const viewInAiAssistantButton = screen.getByTestId('viewInAiAssistant'); + + expect(viewInAiAssistantButton).toBeInTheDocument(); + }); + + it('renders the "Investigate in Timeline" button', () => { + const investigateInTimelineButton = screen.getByTestId('investigateInTimelineButton'); + + expect(investigateInTimelineButton).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx index bd36a047c5a1b6..23a63d0503db39 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx @@ -85,7 +85,7 @@ const AttackDiscoveryTabComponent: React.FC = ({ {tacticMetadata.length > 0 && ( <> - +

{i18n.ATTACK_CHAIN}

diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx new file mode 100644 index 00000000000000..d002c0bde5324d --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.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 type { Replacements } from '@kbn/elastic-assistant-common'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { getTabs } from './get_tabs'; +import { TestProviders } from '../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { ALERTS, ATTACK_DISCOVERY } from './translations'; + +describe('getTabs', () => { + const mockReplacements: Replacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + '3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username', + }; + + const tabs = getTabs({ + attackDiscovery: mockAttackDiscovery, + replacements: mockReplacements, + }); + + describe('Attack discovery tab', () => { + const attackDiscoveryTab = tabs.find((tab) => tab.id === 'attackDiscovery--id'); + + it('includes the Attack discovery tab', () => { + expect(attackDiscoveryTab).not.toBeUndefined(); + }); + + it('has the expected tab name', () => { + expect(attackDiscoveryTab?.name).toEqual(ATTACK_DISCOVERY); + }); + + it('renders the expected content', () => { + render({attackDiscoveryTab?.content}); + + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); + }); + }); + + describe('Alerts tab', () => { + const alertsTab = tabs.find((tab) => tab.id === 'alerts--id'); + + it('includes the Alerts tab', () => { + expect(alertsTab).not.toBeUndefined(); + }); + + it('has the expected tab name', () => { + expect(alertsTab?.name).toEqual(ALERTS); + }); + + it('renders the expected content', () => { + render({alertsTab?.content}); + + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx index 2cf7905b0c3ca7..09708d0880c8a0 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx @@ -13,7 +13,7 @@ import { AttackDiscoveryTab } from './attack_discovery_tab'; import { AlertsTab } from './alerts_tab'; import * as i18n from './translations'; -interface TabInfo { +export interface TabInfo { content: JSX.Element; id: string; name: string; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx new file mode 100644 index 00000000000000..3b155d704708c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx @@ -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 { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Tabs } from '.'; +import { TestProviders } from '../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; + +describe('Tabs', () => { + beforeEach(() => { + render( + + + + ); + }); + + it('renders the attack discovery tab', () => { + const attackDiscoveryTab = screen.getByTestId('attackDiscoveryTab'); + + expect(attackDiscoveryTab).toBeInTheDocument(); + }); + + it("renders the alerts tab when it's selected", () => { + const alertsTabButton = screen.getByText('Alerts'); + + fireEvent.click(alertsTabButton); + const alertsTab = screen.getByTestId('alertsTab'); + + expect(alertsTab).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.tsx new file mode 100644 index 00000000000000..8648d861b03522 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Title } from '.'; + +describe('Title', () => { + const title = 'Malware Delivery and Credentials Access on macOS'; + + it('renders the assistant avatar', () => { + render(); + const assistantAvatar = screen.getByTestId('assistantAvatar'); + + expect(assistantAvatar).toBeInTheDocument(); + }); + + it('renders the expected title', () => { + render(<Title isLoading={false} title={title} />); + const titleText = screen.getByTestId('titleText'); + + expect(titleText).toHaveTextContent(title); + }); + + it('renders the skeleton title when isLoading is true', () => { + render(<Title isLoading={true} title={title} />); + const skeletonTitle = screen.getByTestId('skeletonTitle'); + + expect(skeletonTitle).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx new file mode 100644 index 00000000000000..322e26cb4df48c --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { ViewInAiAssistant } from '.'; +import { TestProviders } from '../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { VIEW_IN_AI_ASSISTANT } from './translations'; + +describe('ViewInAiAssistant', () => { + it('renders the assistant avatar', () => { + render( + <TestProviders> + <ViewInAiAssistant attackDiscovery={mockAttackDiscovery} /> + </TestProviders> + ); + + const assistantAvatar = screen.getByTestId('assistantAvatar'); + + expect(assistantAvatar).toBeInTheDocument(); + }); + + it('renders the expected button label', () => { + render( + <TestProviders> + <ViewInAiAssistant attackDiscovery={mockAttackDiscovery} /> + </TestProviders> + ); + + const viewInAiAssistantLabel = screen.getByTestId('viewInAiAssistantLabel'); + + expect(viewInAiAssistantLabel).toHaveTextContent(VIEW_IN_AI_ASSISTANT); + }); + + describe('compact mode', () => { + it('does NOT render the assistant avatar', () => { + render( + <TestProviders> + <ViewInAiAssistant attackDiscovery={mockAttackDiscovery} compact={true} /> + </TestProviders> + ); + + const assistantAvatar = screen.queryByTestId('assistantAvatar'); + + expect(assistantAvatar).not.toBeInTheDocument(); + }); + + it('renders the expected button text', () => { + render( + <TestProviders> + <ViewInAiAssistant attackDiscovery={mockAttackDiscovery} compact={true} /> + </TestProviders> + ); + + const viewInAiAssistantCompact = screen.getByTestId('viewInAiAssistantCompact'); + + expect(viewInAiAssistantCompact).toHaveTextContent(VIEW_IN_AI_ASSISTANT); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts new file mode 100644 index 00000000000000..cc7058c8f3fe6b --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.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 { renderHook } from '@testing-library/react-hooks'; +import { useAssistantOverlay } from '@kbn/elastic-assistant'; + +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { useViewInAiAssistant } from './use_view_in_ai_assistant'; + +jest.mock('@kbn/elastic-assistant'); +jest.mock('../../../assistant/use_assistant_availability'); +jest.mock('../../get_attack_discovery_markdown/get_attack_discovery_markdown'); + +describe('useViewInAiAssistant', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useAssistantOverlay as jest.Mock).mockReturnValue({ + promptContextId: 'prompt-context-id', + showAssistantOverlay: jest.fn(), + }); + + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + (getAttackDiscoveryMarkdown as jest.Mock).mockResolvedValue('Test markdown'); + }); + + it('returns the expected promptContextId', () => { + const { result } = renderHook(() => + useViewInAiAssistant({ + attackDiscovery: mockAttackDiscovery, + }) + ); + + expect(result.current.promptContextId).toBe('prompt-context-id'); + }); + + it('returns disabled: false when the user has assistant privileges and promptContextId is provided', () => { + const { result } = renderHook(() => + useViewInAiAssistant({ + attackDiscovery: mockAttackDiscovery, + }) + ); + + expect(result.current.disabled).toBe(false); + }); + + it('returns disabled: true when the user does NOT have assistant privileges', () => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: false, // <-- the user does NOT have assistant privileges + isAssistantEnabled: true, + }); + + const { result } = renderHook(() => + useViewInAiAssistant({ + attackDiscovery: mockAttackDiscovery, + }) + ); + + expect(result.current.disabled).toBe(true); + }); + + it('returns disabled: true when promptContextId is null', () => { + (useAssistantOverlay as jest.Mock).mockReturnValue({ + promptContextId: null, // <-- promptContextId is null + showAssistantOverlay: jest.fn(), + }); + + const { result } = renderHook(() => + useViewInAiAssistant({ + attackDiscovery: mockAttackDiscovery, + }) + ); + + expect(result.current.disabled).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx new file mode 100644 index 00000000000000..4af83edba69aad --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + getAttackChainMarkdown, + getAttackDiscoveryMarkdown, + getMarkdownFields, + getMarkdownWithOriginalValues, +} from './get_attack_discovery_markdown'; +import { mockAttackDiscovery } from '../mock/mock_attack_discovery'; + +describe('getAttackDiscoveryMarkdown', () => { + describe('getMarkdownFields', () => { + it('replaces markdown fields with formatted values', () => { + const markdown = 'This is a {{ field1 value1 }} and {{ field2 value2 }}.'; + const expected = 'This is a `value1` and `value2`.'; + + const result = getMarkdownFields(markdown); + + expect(result).toBe(expected); + }); + + it('handles multiple occurrences of markdown fields', () => { + const markdown = + 'This is a {{ field1 value1 }} and {{ field2 value2 }}. Also, {{ field1 value3 }}.'; + const expected = 'This is a `value1` and `value2`. Also, `value3`.'; + + const result = getMarkdownFields(markdown); + + expect(result).toBe(expected); + }); + + it('handles markdown fields with no spaces around them', () => { + const markdown = 'This is a {{field1 value1}} and {{field2 value2}}.'; + const expected = 'This is a `value1` and `value2`.'; + + const result = getMarkdownFields(markdown); + + expect(result).toBe(expected); + }); + + it('handles empty markdown', () => { + const markdown = ''; + const expected = ''; + + const result = getMarkdownFields(markdown); + + expect(result).toBe(expected); + }); + }); + + describe('getAttackChainMarkdown', () => { + it('returns an empty string when no tactics are detected', () => { + const noTactics = { + ...mockAttackDiscovery, + mitreAttackTactics: [], + }; + + const result = getAttackChainMarkdown(noTactics); + + expect(result).toBe(''); + }); + + it('returns the expected attack chain markdown when tactics are detected', () => { + const result = getAttackChainMarkdown(mockAttackDiscovery); + + expect(result).toBe(`### Attack Chain +- Initial Access +- Execution +- Persistence +- Privilege Escalation +`); + }); + }); + + describe('getMarkdownWithOriginalValues', () => { + const markdown = mockAttackDiscovery.summaryMarkdown; + + it('returns the same markdown when no replacements are provided', () => { + const result = getMarkdownWithOriginalValues({ markdown }); + + expect(result).toBe(markdown); + }); + + it('replaces the UUIDs with the original values when replacements are provided ', () => { + const replacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + '3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username', + }; + const expected = + 'A multi-stage malware attack was detected on {{ host.name foo.hostname }} involving {{ user.name bar.username }}. A suspicious application delivered malware, attempted credential theft, and established persistence.'; + + const result = getMarkdownWithOriginalValues({ markdown, replacements }); + + expect(result).toBe(expected); + }); + + it('only replaces values when there are corresponding entries in the replacements', () => { + // The UUID '3bdc7952-a334-4d95-8092-cd176546e18a' is not in the replacements: + const replacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + }; + + const expected = + 'A multi-stage malware attack was detected on {{ host.name foo.hostname }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.'; + + const result = getMarkdownWithOriginalValues({ markdown, replacements }); + + expect(result).toBe(expected); + }); + }); + + describe('getAttackDiscoveryMarkdown', () => { + it('returns the expected markdown when replacements are NOT provided', () => { + const expectedMarkdown = `## Malware Attack With Credential Theft Attempt + +Suspicious activity involving the host \`5e454c38-439c-4096-8478-0a55511c76e3\` and user \`3bdc7952-a334-4d95-8092-cd176546e18a\`. + +### Summary +A multi-stage malware attack was detected on \`5e454c38-439c-4096-8478-0a55511c76e3\` involving \`3bdc7952-a334-4d95-8092-cd176546e18a\`. A suspicious application delivered malware, attempted credential theft, and established persistence. + +### Details +The following attack progression appears to have occurred on the host \`5e454c38-439c-4096-8478-0a55511c76e3\` involving the user \`3bdc7952-a334-4d95-8092-cd176546e18a\`: + +- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. +- This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. +- The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. +- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. + +This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection. + +### Attack Chain +- Initial Access +- Execution +- Persistence +- Privilege Escalation + +`; + + const markdown = getAttackDiscoveryMarkdown({ attackDiscovery: mockAttackDiscovery }); + + expect(markdown).toBe(expectedMarkdown); + }); + + it('returns the expected markdown when replacements are provided', () => { + const replacements = { + '5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname', + '3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username', + }; + + const expectedMarkdown = `## Malware Attack With Credential Theft Attempt + +Suspicious activity involving the host \`foo.hostname\` and user \`bar.username\`. + +### Summary +A multi-stage malware attack was detected on \`foo.hostname\` involving \`bar.username\`. A suspicious application delivered malware, attempted credential theft, and established persistence. + +### Details +The following attack progression appears to have occurred on the host \`foo.hostname\` involving the user \`bar.username\`: + +- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. +- This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. +- The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. +- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. + +This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection. + +### Attack Chain +- Initial Access +- Execution +- Persistence +- Privilege Escalation + +`; + + const markdown = getAttackDiscoveryMarkdown({ + attackDiscovery: mockAttackDiscovery, + replacements, + }); + + expect(markdown).toBe(expectedMarkdown); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx new file mode 100644 index 00000000000000..4f5e43323333f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + COMMAND_AND_CONTROL, + DISCOVERY, + EXECUTION, + EXFILTRATION, + getTacticLabel, + getTacticMetadata, + INITIAL_ACCESS, + LATERAL_MOVEMENT, + PERSISTENCE, + PRIVILEGE_ESCALATION, + RECONNAISSANCE, + replaceNewlineLiterals, +} from './helpers'; +import { mockAttackDiscovery } from './mock/mock_attack_discovery'; +import * as i18n from './translations'; + +const expectedTactics = { + [RECONNAISSANCE]: i18n.RECONNAISSANCE, + [INITIAL_ACCESS]: i18n.INITIAL_ACCESS, + [EXECUTION]: i18n.EXECUTION, + [PERSISTENCE]: i18n.PERSISTENCE, + [PRIVILEGE_ESCALATION]: i18n.PRIVILEGE_ESCALATION, + [DISCOVERY]: i18n.DISCOVERY, + [LATERAL_MOVEMENT]: i18n.LATERAL_MOVEMENT, + [COMMAND_AND_CONTROL]: i18n.COMMAND_AND_CONTROL, + [EXFILTRATION]: i18n.EXFILTRATION, + unknown: 'unknown', +}; + +describe('helpers', () => { + describe('getTacticLabel', () => { + Object.entries(expectedTactics).forEach(([tactic, expectedLabel]) => { + it(`returns the expected label for ${tactic}`, () => { + const label = getTacticLabel(tactic); + + expect(label).toBe(expectedLabel); + }); + }); + }); + + describe('getTacticMetadata', () => { + const expectedDetected = ['Initial Access', 'Execution', 'Persistence', 'Privilege Escalation']; + + expectedDetected.forEach((tactic) => { + it(`sets the detected property to true for the '${tactic}' tactic`, () => { + const result = getTacticMetadata(mockAttackDiscovery); + const metadata = result.find(({ name }) => name === tactic); + + expect(metadata?.detected).toBe(true); + }); + }); + + it('sets the detected property to false for all tactics that were not detected', () => { + const result = getTacticMetadata(mockAttackDiscovery); + const filtered = result.filter(({ name }) => !expectedDetected.includes(name)); + + filtered.forEach((metadata) => { + expect(metadata.detected).toBe(false); + }); + }); + + it('sets the expected "index" property for each tactic', () => { + const result = getTacticMetadata(mockAttackDiscovery); + + result.forEach((metadata, i) => { + expect(metadata.index).toBe(i); + }); + }); + }); + + describe('replaceNewlineLiterals', () => { + it('replaces multiple newline literals with actual newlines', () => { + const input = 'Multiple\\nnewline\\nliterals'; + const expected = 'Multiple\nnewline\nliterals'; + + const result = replaceNewlineLiterals(input); + + expect(result).toEqual(expected); + }); + + it('does NOT replace anything if there are no newline literals', () => { + const input = 'This is a string without newlines'; + const result = replaceNewlineLiterals(input); + + expect(result).toEqual(input); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/helpers.ts index 731f1985c7b136..aa56835d5a1ed9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/helpers.ts @@ -56,7 +56,7 @@ export const getTacticLabel = (tactic: string): string => { } }; -interface TacticMetadata { +export interface TacticMetadata { detected: boolean; index: number; name: string; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.ts b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.ts new file mode 100644 index 00000000000000..d5de6e8d7cc060 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.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 type { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +export const mockAttackDiscovery: AttackDiscovery = { + alertIds: [ + '639801cdb10a93610be4a91fe0eac94cd3d4d292cf0c2a6d7b3674d7f7390357', + 'bdcf649846dc3ed0ca66537e1c1dc62035a35a208ba4d9853a93e9be4b0dbea3', + 'cdbd13134bbd371cd045e5f89970b21ab866a9c3817b2aaba8d8e247ca88b823', + '58571e1653b4201c4f35d49b6eb4023fc0219d5885ff7c385a9253a692a77104', + '06fcb3563de7dad14137c0bb4e5bae17948c808b8a3b8c60d9ec209a865b20ed', + '8bd3fcaeca5698ee26df402c8bc40df0404d34a278bc0bd9355910c8c92a4aee', + '59ff4efa1a03b0d1cb5c0640f5702555faf5c88d273616c1b6e22dcfc47ac46c', + 'f352f8ca14a12062cde77ff2b099202bf74f4a7d757c2ac75ac63690b2f2f91a', + ], + detailsMarkdown: + 'The following attack progression appears to have occurred on the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving the user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}:\n\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.\n- This application spawned child processes to copy a malicious file named "unix1" to the user\'s home directory and make it executable.\n- The malicious "unix1" file was then executed, attempting to access the user\'s login keychain and potentially exfiltrate credentials.\n- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.\n\nThis appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.', + entitySummaryMarkdown: + 'Suspicious activity involving the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} and user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}.', + id: 'e6d1f8ef-7c1d-42d6-ba6a-11610bab72b1', + mitreAttackTactics: [ + 'Initial Access', + 'Execution', + 'Persistence', + 'Privilege Escalation', + 'Credential Access', + ], + summaryMarkdown: + 'A multi-stage malware attack was detected on {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.', + timestamp: '2024-06-25T21:14:40.098Z', + title: 'Malware Attack With Credential Theft Attempt', +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts index 617f599e09c8f9..4f8be970f40a1f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts @@ -195,3 +195,87 @@ export const getMockUseAttackDiscoveriesWithNoAttackDiscoveriesLoading = ( replacements: {}, isLoading: true, // <-- attack discoveries are being generated }); + +export const getRawAttackDiscoveryResponse = () => ({ + alertsContextCount: 20, + attackDiscoveries: [ + { + alertIds: [ + '382d546a7ba5ab35c050f106bece236e87e3d51076a479f0beae8b2015b8fb26', + 'ca9da6b3b77b7038d958b9e144f0a406c223a862c0c991ce9782b98e03a98c87', + '5301f4fb014538df7ce1eb9929227dde3adc0bf5b4f28aa15c8aa4e4fda95f35', + '1459af4af8b92e1710c0ee075b1c444eaa927583dfd71b42e9a10de37c8b9cf0', + '468457e9c5132aadae501b75ec5b766e1465ab865ad8d79e03f66593a76fccdf', + 'fb92e7fa5679db3e91d84d998faddb7ed269f1c8cdc40443f35e67c930383d34', + '03e0f8f1598018da8143bba6b60e6ddea30551a2286ba76d717568eed3d17a66', + '28021a7aca7de03018d820182c9784f8d5f2e1b99e0159177509a69bee1c3ac0', + ], + detailsMarkdown: + 'The following attack progression appears to have occurred on the host {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} involving the user {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }}:\\n\\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation\\n- This application attempted to run various malicious scripts and commands, including:\\n - Spawning a child process to run the "osascript" utility to display a fake system dialog prompting for user credentials ({{ 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 ¬ }})\\n - Modifying permissions on a suspicious file named "unix1" ({{ process.command_line chmod 777 /Users/james/unix1 }})\\n - Executing the suspicious "unix1" file and passing it the user\'s login keychain file and a hardcoded password ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\\n\\nThis appears to be a multi-stage malware attack, potentially aimed at credential theft and further malicious execution on the compromised host. The tactics used align with Credential Access ({{ threat.tactic.name Credential Access }}) and Execution ({{ threat.tactic.name Execution }}) based on MITRE ATT&CK.', + entitySummaryMarkdown: + 'Suspicious activity detected on {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} involving {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }}.', + mitreAttackTactics: ['Credential Access', 'Execution'], + summaryMarkdown: + 'A multi-stage malware attack was detected on a macOS host, likely initiated through a malicious application download. The attack involved credential phishing attempts, suspicious file modifications, and the execution of untrusted binaries potentially aimed at credential theft. {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} and {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }} were involved.', + title: 'Credential Theft Malware Attack on macOS', + }, + { + alertIds: [ + '8772effc4970e371a26d556556f68cb8c73f9d9d9482b7f20ee1b1710e642a23', + '63c761718211fa51ea797669d845c3d4f23b1a28c77a101536905e6fd0b4aaa6', + '55f4641a9604e1088deae4897e346e63108bde9167256c7cb236164233899dcc', + 'eaf9991c83feef7798983dc7cacda86717d77136a3a72c9122178a03ce2f15d1', + 'f7044f707ac119256e5a0ccd41d451b51bca00bdc6899c7e5e8e1edddfeb6774', + 'fad83b4223f3c159646ad22df9877b9c400f9472655e49781e2a5951b641088e', + ], + detailsMarkdown: + 'The following attack progression appears to have occurred on the host {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} involving the user {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}:\\n\\n- A malicious Microsoft Office document was opened, spawning a child process to write a suspicious VBScript file named "AppPool.vbs" ({{ file.path C:\\ProgramData\\WindowsAppPool\\AppPool.vbs }})\\n- The VBScript launched PowerShell and executed an obfuscated script from "AppPool.ps1"\\n- Additional malicious activities were performed, including:\\n - Creating a scheduled task to periodically execute the VBScript\\n - Spawning a cmd.exe process to create the scheduled task\\n - Executing the VBScript directly\\n\\nThis appears to be a multi-stage malware attack initiated through malicious Office documents, employing script obfuscation, scheduled task persistence, and defense evasion tactics. The activities map to Initial Access ({{ threat.tactic.name Initial Access }}), Execution ({{ threat.tactic.name Execution }}), and Defense Evasion ({{ threat.tactic.name Defense Evasion }}) based on MITRE ATT&CK.', + entitySummaryMarkdown: + 'Suspicious activity detected on {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} involving {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'], + summaryMarkdown: + 'A multi-stage malware attack was detected on a Windows host, likely initiated through a malicious Microsoft Office document. The attack involved script obfuscation, scheduled task persistence, and other defense evasion tactics. {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} and {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }} were involved.', + title: 'Malicious Office Document Initiates Malware Attack', + }, + { + alertIds: [ + 'd1b8b1c6f891fd181af236d0a81b8769c4569016d5b341cdf6a3fefb7cf9cbfd', + '005f2dfb7efb08b34865b308876ecad188fc9a3eebf35b5e3af3c3780a3fb239', + '7e41ddd221831544c5ff805e0ec31fc3c1f22c04257de1366112cfef14df9f63', + ], + detailsMarkdown: + 'The following attack progression appears to have occurred on the host {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} involving the user {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}:\\n\\n- A suspicious process launched by msiexec.exe spawned a PowerShell session\\n- The PowerShell process exhibited the following malicious behaviors:\\n - Shellcode injection detected, indicating the presence of the "Windows.Trojan.Bumblebee" malware\\n - Establishing network connections, suggesting command and control or data exfiltration\\n\\nThis appears to be a case of malware delivery and execution via an MSI package, potentially initiated through a software supply chain compromise or social engineering attack. The tactics employed align with Defense Evasion ({{ threat.tactic.name Defense Evasion }}) through system binary proxy execution, as well as potential Command and Control ({{ threat.tactic.name Command and Control }}) based on MITRE ATT&CK.', + entitySummaryMarkdown: + 'Suspicious activity detected on {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} involving {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}.', + mitreAttackTactics: ['Defense Evasion', 'Command and Control'], + summaryMarkdown: + 'A malware attack was detected on a Windows host, likely delivered through a compromised MSI package. The attack involved shellcode injection, network connections, and the use of system binaries for defense evasion. {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} and {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }} were involved.', + title: 'Malware Delivery via Compromised MSI Package', + }, + { + alertIds: [ + '12057d82e79068080f6acf268ca45c777d3f80946b466b59954320ec5f86f24a', + '81c7c57a360bee531b1398b0773e7c4a2332fbdda4e66f135e01fc98ec7f4e3d', + ], + detailsMarkdown: + 'The following attack progression appears to have occurred on the host {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} involving the user {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }}:\\n\\n- A malicious file named "kdmtmpflush" with the SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was copied to the /dev/shm directory\\n- Permissions were modified to make the file executable\\n- The file was then executed with the "--init" argument, likely to initialize malicious components\\n\\nThis appears to be a case of the "Linux.Trojan.BPFDoor" malware being deployed on the Linux host. The tactics employed align with Execution ({{ threat.tactic.name Execution }}) based on MITRE ATT&CK.', + entitySummaryMarkdown: + 'Suspicious activity detected on {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} involving {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }}.', + mitreAttackTactics: ['Execution'], + summaryMarkdown: + 'The "Linux.Trojan.BPFDoor" malware was detected being deployed on a Linux host. A malicious file was copied, permissions were modified, and the file was executed to likely initialize malicious components. {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} and {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }} were involved.', + title: 'Linux.Trojan.BPFDoor Malware Deployment Detected', + }, + ], + connector_id: 'pmeClaudeV3SonnetUsEast1', + replacements: { + 'ddc8db29-46eb-44fe-80b6-1ea642c338ac': 'james', + '05207978-1585-4e46-9b36-69c4bb85a768': 'SRVMAC08', + '7245f867-9a09-48d7-9165-84a69fa0727d': 'root', + 'e411fe2e-aeea-44b5-b09a-4336dabb3969': 'Administrator', + '5a63f6dc-4e40-41fe-a92c-7898e891025e': 'SRVWIN07-PRIV', + 'b775910b-4b71-494d-bfb1-4be3fe88c2b0': 'SRVWIN07', + 'c1e00157-c636-4222-b3a2-5d9ea667a3a8': 'SRVWIN06', + 'd4c92b0d-b82f-4702-892d-dd06ad8418e8': 'SRVNIX05', + }, +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.tsx new file mode 100644 index 00000000000000..a70bfaa5c9951a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.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. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AnimatedCounter } from '.'; + +describe('AnimatedCounter', () => { + it('renders the expected final count', async () => { + const animationDurationMs = 10; // ms + const count = 20; + + render(<AnimatedCounter animationDurationMs={animationDurationMs} count={count} />); + await new Promise((resolve) => setTimeout(resolve, animationDurationMs + 10)); + + const animatedCounter = screen.getByTestId('animatedCounter'); + + expect(animatedCounter).toHaveTextContent(`${count}`); + }); +}); 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 2428158aa5b71e..5dd4cb8fc4267a 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 @@ -11,14 +11,14 @@ import * as d3 from 'd3'; import React, { useRef, useEffect } from 'react'; interface Props { + animationDurationMs?: number; count: number; } -const AnimatedCounterComponent: React.FC<Props> = ({ count }) => { +const AnimatedCounterComponent: React.FC<Props> = ({ animationDurationMs = 1000 * 1, count }) => { const { euiTheme } = useEuiTheme(); const d3Ref = useRef(null); const zero = 0; // counter starts at zero - const animationDurationMs = 1000 * 1; useEffect(() => { if (d3Ref.current) { 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 new file mode 100644 index 00000000000000..70acc1dbb2ca80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.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 { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EmptyPrompt } from '.'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../../../assistant/use_assistant_availability'); + +describe('EmptyPrompt', () => { + const alertsCount = 20; + const onGenerate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when the user has the assistant privilege', () => { + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + alertsCount={alertsCount} + isLoading={false} + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('renders the empty prompt avatar', () => { + const emptyPromptAvatar = screen.getByTestId('emptyPromptAvatar'); + + expect(emptyPromptAvatar).toBeInTheDocument(); + }); + + it('renders the animated counter', () => { + const emptyPromptAnimatedCounter = screen.getByTestId('emptyPromptAnimatedCounter'); + + expect(emptyPromptAnimatedCounter).toBeInTheDocument(); + }); + + it('renders the expected statement', () => { + const emptyPromptAlertsWillBeAnalyzed = screen.getByTestId('emptyPromptAlertsWillBeAnalyzed'); + + expect(emptyPromptAlertsWillBeAnalyzed).toHaveTextContent('alerts will be analyzed'); + }); + + it('calls onGenerate when the generate button is clicked', () => { + const generateButton = screen.getByTestId('generate'); + + fireEvent.click(generateButton); + + expect(onGenerate).toHaveBeenCalled(); + }); + }); + + describe('when the user does NOT have the assistant privilege', () => { + it('disables the generate button when the user does NOT have the assistant privilege', () => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: false, // <-- the user does NOT have the assistant privilege + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + alertsCount={alertsCount} + isLoading={false} + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + + const generateButton = screen.getByTestId('generate'); + + expect(generateButton).toBeDisabled(); + }); + }); + + describe('when loading is true', () => { + const isLoading = true; + + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + alertsCount={alertsCount} + isLoading={isLoading} + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('disables the generate button while loading', () => { + const generateButton = screen.getByTestId('generate'); + + expect(generateButton).toBeDisabled(); + }); + }); + + describe('when isDisabled is true', () => { + const isDisabled = true; + + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + alertsCount={alertsCount} + isLoading={false} + isDisabled={isDisabled} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('disables the generate button when isDisabled is true', () => { + const generateButton = screen.getByTestId('generate'); + + expect(generateButton).toBeDisabled(); + }); + }); +}); 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 new file mode 100644 index 00000000000000..18dddaea3abdc7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 React from 'react'; + +import { Header } from '.'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../../../assistant/use_assistant_availability'); + +describe('Header', () => { + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + }); + + it('renders the connector selector', () => { + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={false} + onCancel={jest.fn()} + onGenerate={jest.fn()} + onConnectorIdSelected={jest.fn()} + /> + </TestProviders> + ); + + const connectorSelector = screen.getByTestId('connectorSelectorPlaceholderButton'); + + expect(connectorSelector).toBeInTheDocument(); + }); + + it('does NOT render the connector selector when connectors are NOT configured', () => { + const connectorsAreConfigured = false; + + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={connectorsAreConfigured} + isDisabledActions={false} + isLoading={false} + onCancel={jest.fn()} + onGenerate={jest.fn()} + onConnectorIdSelected={jest.fn()} + /> + </TestProviders> + ); + + const connectorSelector = screen.queryByTestId('connectorSelectorPlaceholderButton'); + + expect(connectorSelector).not.toBeInTheDocument(); + }); + + it('invokes onGenerate when the generate button is clicked', () => { + const onGenerate = jest.fn(); + + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={false} + onCancel={jest.fn()} + onConnectorIdSelected={jest.fn()} + onGenerate={onGenerate} + /> + </TestProviders> + ); + + const generate = screen.getByTestId('generate'); + + fireEvent.click(generate); + + expect(onGenerate).toHaveBeenCalled(); + }); + + it('disables the generate button when the user does NOT have the assistant privilege', () => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: false, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={false} + onCancel={jest.fn()} + onConnectorIdSelected={jest.fn()} + onGenerate={jest.fn()} + /> + </TestProviders> + ); + + const generate = screen.getByTestId('generate'); + + expect(generate).toBeDisabled(); + }); + + it('displays the cancel button when loading', () => { + const isLoading = true; + + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={isLoading} + onCancel={jest.fn()} + onConnectorIdSelected={jest.fn()} + onGenerate={jest.fn()} + /> + </TestProviders> + ); + + const cancel = screen.getByTestId('cancel'); + + expect(cancel).toBeInTheDocument(); + }); + + it('invokes onCancel when the cancel button is clicked', () => { + const isLoading = true; + const onCancel = jest.fn(); + + render( + <TestProviders> + <Header + connectorId="testConnectorId" + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={isLoading} + onCancel={onCancel} + onConnectorIdSelected={jest.fn()} + onGenerate={jest.fn()} + /> + </TestProviders> + ); + + const cancel = screen.getByTestId('cancel'); + fireEvent.click(cancel); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('disables the generate button when connectorId is undefined', () => { + const connectorId = undefined; + + render( + <TestProviders> + <Header + connectorId={connectorId} + connectorsAreConfigured={true} + isDisabledActions={false} + isLoading={false} + onCancel={jest.fn()} + onConnectorIdSelected={jest.fn()} + onGenerate={jest.fn()} + /> + </TestProviders> + ); + + const generate = screen.getByTestId('generate'); + + expect(generate).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/index.test.tsx new file mode 100644 index 00000000000000..14a707958b888e --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/index.test.tsx @@ -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. + */ + +import moment from 'moment'; + +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Countdown } from '.'; +import { TestProviders } from '../../../../common/mock'; +import { APPROXIMATE_TIME_REMAINING } from './translations'; +import type { GenerationInterval } from '@kbn/elastic-assistant-common'; + +describe('Countdown', () => { + const connectorIntervals: GenerationInterval[] = [ + { + date: '2024-05-16T14:13:09.838Z', + durationMs: 173648, + }, + { + date: '2024-05-16T13:59:49.620Z', + durationMs: 146605, + }, + { + date: '2024-05-16T13:47:00.629Z', + durationMs: 255163, + }, + ]; + + beforeAll(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + + beforeEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('returns null when connectorIntervals is empty', () => { + const { container } = render( + <TestProviders> + <Countdown approximateFutureTime={null} connectorIntervals={[]} /> + </TestProviders> + ); + + expect(container.innerHTML).toEqual(''); + }); + + it('renders the expected prefix', () => { + render( + <TestProviders> + <Countdown approximateFutureTime={null} connectorIntervals={connectorIntervals} /> + </TestProviders> + ); + + expect(screen.getByTestId('prefix')).toHaveTextContent(APPROXIMATE_TIME_REMAINING); + }); + + it('renders the expected the timer text', () => { + const approximateFutureTime = moment().add(1, 'minute').toDate(); + + render( + <TestProviders> + <Countdown + approximateFutureTime={approximateFutureTime} + connectorIntervals={connectorIntervals} + /> + </TestProviders> + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(screen.getByTestId('timerText')).toHaveTextContent('00:59'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/generation_timing/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/generation_timing/index.test.tsx new file mode 100644 index 00000000000000..35a72d6455f2b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/generation_timing/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../../../common/mock'; +import { GenerationTiming } from '.'; + +describe('GenerationTiming', () => { + const interval = { + connectorId: 'claudeV3SonnetUsEast1', + date: '2024-04-15T13:48:44.397Z', + durationMs: 5000, + }; + + beforeEach(() => { + render( + <TestProviders> + <GenerationTiming interval={interval} /> + </TestProviders> + ); + }); + + it('renders the expected duration in seconds', () => { + const durationText = screen.getByTestId('clockBadge').textContent; + + expect(durationText).toEqual('5s'); + }); + + it('displays the expected date', () => { + const date = screen.getByTestId('date').textContent; + + expect(date).toEqual('Apr 15, 2024 @ 13:48:44.397'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/helpers.test.ts new file mode 100644 index 00000000000000..6e7b12f0a51c7f --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/helpers.test.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 moment from 'moment'; + +import { getAverageIntervalSeconds, getTimerPrefix } from './helpers'; +import { APPROXIMATE_TIME_REMAINING, ABOVE_THE_AVERAGE_TIME } from '../translations'; +import type { GenerationInterval } from '@kbn/elastic-assistant-common'; + +describe('helpers', () => { + describe('getAverageIntervalSeconds', () => { + it('returns 0 when the intervals array is empty', () => { + const intervals: GenerationInterval[] = []; + + const average = getAverageIntervalSeconds(intervals); + + expect(average).toEqual(0); + }); + + it('calculates the average interval in seconds', () => { + const intervals: GenerationInterval[] = [ + { + date: '2024-04-15T13:48:44.397Z', + durationMs: 85807, + }, + { + date: '2024-04-15T12:41:15.255Z', + durationMs: 12751, + }, + { + date: '2024-04-12T20:59:13.238Z', + durationMs: 46169, + }, + { + date: '2024-04-12T19:34:56.701Z', + durationMs: 86674, + }, + ]; + + const average = getAverageIntervalSeconds(intervals); + + expect(average).toEqual(57); + }); + }); + + describe('getTimerPrefix', () => { + it('returns APPROXIMATE_TIME_REMAINING when approximateFutureTime is null', () => { + const approximateFutureTime: Date | null = null; + + const result = getTimerPrefix(approximateFutureTime); + + expect(result).toEqual(APPROXIMATE_TIME_REMAINING); + }); + + it('returns APPROXIMATE_TIME_REMAINING when approximateFutureTime is in the future', () => { + const approximateFutureTime = moment().add(1, 'minute').toDate(); + const result = getTimerPrefix(approximateFutureTime); + + expect(result).toEqual(APPROXIMATE_TIME_REMAINING); + }); + + it('returns ABOVE_THE_AVERAGE_TIME when approximateFutureTime is in the past', () => { + const approximateFutureTime = moment().subtract(1, 'minute').toDate(); + + const result = getTimerPrefix(approximateFutureTime); + + expect(result).toEqual(ABOVE_THE_AVERAGE_TIME); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/index.test.tsx new file mode 100644 index 00000000000000..45ea68f2a780c6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/countdown/last_times_popover/index.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { GenerationInterval } from '@kbn/elastic-assistant-common'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { LastTimesPopover } from '.'; +import { TestProviders } from '../../../../../common/mock'; + +describe('LastTimesPopover', () => { + const connectorIntervals: GenerationInterval[] = [ + { + date: '2024-05-16T14:13:09.838Z', + durationMs: 173648, + }, + { + date: '2024-05-16T13:59:49.620Z', + durationMs: 146605, + }, + { + date: '2024-05-16T13:47:00.629Z', + durationMs: 255163, + }, + ]; + + beforeEach(() => { + render( + <TestProviders> + <LastTimesPopover connectorIntervals={connectorIntervals} /> + </TestProviders> + ); + }); + + it('renders average time calculated message', () => { + const averageTimeIsCalculated = screen.getByTestId('averageTimeIsCalculated'); + + expect(averageTimeIsCalculated).toHaveTextContent( + 'Remaining time is based on the average speed of the last 3 times the same connector generated results.' + ); + }); + + it('renders generation timing for each connector interval', () => { + const generationTimings = screen.getAllByTestId('generationTiming'); + expect(generationTimings.length).toEqual(connectorIntervals.length); + + const expectedDates = [ + 'May 16, 2024 @ 14:13:09.838', + 'May 16, 2024 @ 13:59:49.620', + 'May 16, 2024 @ 13:47:00.629', + ]; + + generationTimings.forEach((timing, i) => expect(timing).toHaveTextContent(expectedDates[i])); + }); +}); 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 new file mode 100644 index 00000000000000..af6efafb3c1dda --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { LoadingCallout } from '.'; +import type { GenerationInterval } from '@kbn/elastic-assistant-common'; +import { TestProviders } from '../../../common/mock'; + +describe('LoadingCallout', () => { + const connectorIntervals: GenerationInterval[] = [ + { + date: '2024-05-16T14:13:09.838Z', + durationMs: 173648, + }, + { + date: '2024-05-16T13:59:49.620Z', + durationMs: 146605, + }, + { + date: '2024-05-16T13:47:00.629Z', + durationMs: 255163, + }, + ]; + + const defaultProps = { + alertsCount: 30, + approximateFutureTime: new Date(), + connectorIntervals, + }; + + it('renders the animated loading icon', () => { + render( + <TestProviders> + <LoadingCallout {...defaultProps} /> + </TestProviders> + ); + + const loadingElastic = screen.getByTestId('loadingElastic'); + + expect(loadingElastic).toBeInTheDocument(); + }); + + it('renders loading messages with the expected count', () => { + render( + <TestProviders> + <LoadingCallout {...defaultProps} /> + </TestProviders> + ); + + const aisCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing'); + + expect(aisCurrentlyAnalyzing).toHaveTextContent( + 'AI is analyzing up to 30 alerts in the last 24 hours to generate discoveries.' + ); + }); + + it('renders the countdown', () => { + render( + <TestProviders> + <LoadingCallout {...defaultProps} /> + </TestProviders> + ); + const countdown = screen.getByTestId('countdown'); + + expect(countdown).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/info_popover_body/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/info_popover_body/index.test.tsx new file mode 100644 index 00000000000000..b264af94dcb1b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/info_popover_body/index.test.tsx @@ -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 { GenerationInterval } from '@kbn/elastic-assistant-common'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { InfoPopoverBody } from '.'; +import { TestProviders } from '../../../../common/mock'; +import { AVERAGE_TIME } from '../countdown/translations'; + +describe('InfoPopoverBody', () => { + const connectorIntervals: GenerationInterval[] = [ + { + date: '2024-05-16T14:13:09.838Z', + durationMs: 173648, + }, + { + date: '2024-05-16T13:59:49.620Z', + durationMs: 146605, + }, + { + date: '2024-05-16T13:47:00.629Z', + durationMs: 255163, + }, + ]; + + it('renders the expected average time', () => { + render( + <TestProviders> + <InfoPopoverBody connectorIntervals={connectorIntervals} /> + </TestProviders> + ); + + const averageTimeBadge = screen.getByTestId('averageTimeBadge'); + + expect(averageTimeBadge).toHaveTextContent('191s'); + }); + + it('renders the expected explanation', () => { + render( + <TestProviders> + <InfoPopoverBody connectorIntervals={connectorIntervals} /> + </TestProviders> + ); + + const averageTimeIsCalculated = screen.getAllByTestId('averageTimeIsCalculated'); + + expect(averageTimeIsCalculated[0]).toHaveTextContent(AVERAGE_TIME); + }); +}); 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 new file mode 100644 index 00000000000000..250a25055791ae --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { LoadingMessages } from '.'; +import { TestProviders } from '../../../../common/mock'; +import { ATTACK_DISCOVERY_GENERATION_IN_PROGRESS } from '../translations'; + +describe('LoadingMessages', () => { + it('renders the expected loading message', () => { + render( + <TestProviders> + <LoadingMessages alertsCount={20} /> + </TestProviders> + ); + const attackDiscoveryGenerationInProgress = screen.getByTestId( + 'attackDiscoveryGenerationInProgress' + ); + + expect(attackDiscoveryGenerationInProgress).toHaveTextContent( + ATTACK_DISCOVERY_GENERATION_IN_PROGRESS + ); + }); + + it('renders the loading message with the expected alerts count', () => { + render( + <TestProviders> + <LoadingMessages alertsCount={20} /> + </TestProviders> + ); + const aiCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing'); + + expect(aiCurrentlyAnalyzing).toHaveTextContent( + 'AI is analyzing up to 20 alerts in the last 24 hours to generate discoveries.' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/page_title/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/page_title/index.test.tsx new file mode 100644 index 00000000000000..0c8ea0501f2d30 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/page_title/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { PageTitle } from '.'; +import { ATTACK_DISCOVERY_PAGE_TITLE } from './translations'; + +describe('PageTitle', () => { + it('renders the expected title', () => { + render(<PageTitle />); + + const attackDiscoveryPageTitle = screen.getByTestId('attackDiscoveryPageTitle'); + + expect(attackDiscoveryPageTitle).toHaveTextContent(ATTACK_DISCOVERY_PAGE_TITLE); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx new file mode 100644 index 00000000000000..43134b14f616dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 React from 'react'; + +import { Summary } from '.'; + +describe('Summary', () => { + const defaultProps = { + alertsCount: 20, + attackDiscoveriesCount: 5, + lastUpdated: new Date(), + onToggleShowAnonymized: jest.fn(), + showAnonymized: false, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the expected summary counts', () => { + render(<Summary {...defaultProps} />); + + const summaryCount = screen.getByTestId('summaryCount'); + + expect(summaryCount).toHaveTextContent('5 discoveries|20 alerts|Generated: a few seconds ago'); + }); + + it('renders the expected button icon when showAnonymized is false', () => { + render(<Summary {...defaultProps} />); + + const toggleAnonymized = screen.getByTestId('toggleAnonymized').querySelector('span'); + + expect(toggleAnonymized).toHaveAttribute('data-euiicon-type', 'eyeClosed'); + }); + + it('renders the expected button icon when showAnonymized is true', () => { + render(<Summary {...defaultProps} showAnonymized={true} />); + + const toggleAnonymized = screen.getByTestId('toggleAnonymized').querySelector('span'); + + expect(toggleAnonymized).toHaveAttribute('data-euiicon-type', 'eye'); + }); + + it('calls onToggleShowAnonymized when toggle button is clicked', () => { + render(<Summary {...defaultProps} />); + + const toggleAnonymized = screen.getByTestId('toggleAnonymized'); + fireEvent.click(toggleAnonymized); + + expect(defaultProps.onToggleShowAnonymized).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx new file mode 100644 index 00000000000000..b8460eafef87b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { SummaryCount } from '.'; + +describe('SummaryCount', () => { + const defaultProps = { + alertsCount: 20, + attackDiscoveriesCount: 5, + lastUpdated: new Date(), + }; + + it('renders the expected count of attack discoveries', () => { + render(<SummaryCount {...defaultProps} />); + + const discoveriesCount = screen.getByTestId('discoveriesCount'); + + expect(discoveriesCount).toHaveTextContent('5 discoveries'); + }); + + it('renders the expected alerts count', () => { + render(<SummaryCount {...defaultProps} />); + + const alertsCount = screen.getByTestId('alertsCount'); + + expect(alertsCount).toHaveTextContent('20 alerts'); + }); + + it('renders a humanized last generated when lastUpdated is provided', () => { + render(<SummaryCount {...defaultProps} />); + + const lastGenerated = screen.getByTestId('lastGenerated'); + + expect(lastGenerated).toHaveTextContent('Generated: a few seconds ago'); + }); + + it('should NOT render the last generated date when lastUpdated is null', () => { + render(<SummaryCount {...defaultProps} lastUpdated={null} />); + + const lastGenerated = screen.queryByTestId('lastGenerated'); + + expect(lastGenerated).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/upgrade/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/upgrade/index.test.tsx new file mode 100644 index 00000000000000..e72f53e9062d7a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/upgrade/index.test.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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Upgrade } from '.'; +import { TestProviders } from '../../../common/mock'; +import { + ATTACK_DISCOVERY_IS_AVAILABLE, + FIND_POTENTIAL_ATTACKS_WITH_AI, + PLEASE_UPGRADE, +} from './translations'; + +describe('Upgrade', () => { + beforeEach(() => { + render( + <TestProviders> + <Upgrade /> + </TestProviders> + ); + }); + + it('renders the assistant avatar', () => { + const assistantAvatar = screen.getByTestId('assistantAvatar'); + + expect(assistantAvatar).toBeInTheDocument(); + }); + + it('renders the expected upgrade title', () => { + const upgradeTitle = screen.getByTestId('upgradeTitle'); + + expect(upgradeTitle).toHaveTextContent(FIND_POTENTIAL_ATTACKS_WITH_AI); + }); + + it('renders the attack discovery availability text', () => { + const attackDiscoveryIsAvailable = screen.getByTestId('attackDiscoveryIsAvailable'); + + expect(attackDiscoveryIsAvailable).toHaveTextContent(ATTACK_DISCOVERY_IS_AVAILABLE); + }); + + it('renders the please upgrade text', () => { + const pleaseUpgrade = screen.getByTestId('pleaseUpgrade'); + + expect(pleaseUpgrade).toHaveTextContent(PLEASE_UPGRADE); + }); + + it('renders the upgrade subscription plans (docs) link', () => { + const upgradeDocs = screen.getByRole('link', { name: 'Subscription plans' }); + + expect(upgradeDocs).toBeInTheDocument(); + }); + + it('renders the upgrade Manage license call to action', () => { + const upgradeCta = screen.getByRole('link', { name: 'Manage license' }); + + expect(upgradeCta).toBeInTheDocument(); + }); +}); 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 new file mode 100644 index 00000000000000..a15cb7090f6cc2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts @@ -0,0 +1,284 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; +import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; +import { omit } from 'lodash/fp'; + +import { getGenAiConfig, getRequestBody } from './helpers'; + +const connector: ActionConnector = { + actionTypeId: '.gen-ai', + config: { + apiProvider: 'Azure OpenAI', + apiUrl: + 'https://example.com/openai/deployments/example/chat/completions?api-version=2024-02-15-preview', + }, + id: '15b4f8df-e2ca-4060-81a1-3bd2a2bffc7e', + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: false, + name: 'Azure OpenAI GPT-4o', + secrets: { secretTextField: 'a secret' }, +}; + +describe('getGenAiConfig', () => { + it('returns undefined when the connector is preconfigured', () => { + const preconfigured = { + ...connector, + isPreconfigured: true, + }; + + const result = getGenAiConfig(preconfigured); + + expect(result).toBeUndefined(); + }); + + it('returns the expected GenAiConfig when the connector is NOT preconfigured', () => { + const result = getGenAiConfig(connector); + + expect(result).toEqual({ + apiProvider: connector.config.apiProvider, + apiUrl: connector.config.apiUrl, + defaultModel: '2024-02-15-preview', + }); + }); + + it('returns the expected defaultModel for Azure OpenAI', () => { + const result = getGenAiConfig(connector); + + expect(result).toEqual({ + apiProvider: connector.config.apiProvider, + apiUrl: connector.config.apiUrl, + defaultModel: '2024-02-15-preview', + }); + }); + + it('returns the an undefined defaultModel for NON-Azure OpenAI when the config does NOT include a default model', () => { + const apiProvider = 'OpenAI'; // <-- NON-Azure OpenAI + const openAiConnector = { + ...connector, + config: { + ...connector.config, + apiProvider, + // config does NOT have a default model + }, + }; + + const result = getGenAiConfig(openAiConnector); + + expect(result).toEqual({ + apiProvider, + apiUrl: connector.config.apiUrl, + defaultModel: undefined, // <-- because the config does not have a default model + }); + }); + + it('returns the expected defaultModel for NON-Azure OpenAi when the config has a default model', () => { + const apiProvider = 'OpenAI'; // <-- NON-Azure OpenAI + const withDefaultModel = { + ...connector, + config: { + ...connector.config, + apiProvider, + defaultModel: 'aDefaultModel', // <-- default model is specified + }, + }; + + const result = getGenAiConfig(withDefaultModel); + + expect(result).toEqual({ + apiProvider, + apiUrl: connector.config.apiUrl, + defaultModel: 'aDefaultModel', + }); + }); + + it('returns the expected GenAiConfig when the connector config is undefined', () => { + const connectorWithoutConfig = omit('config', connector) as ActionConnector< + Record<string, unknown>, + Record<string, unknown> + >; + + const result = getGenAiConfig(connectorWithoutConfig); + + expect(result).toEqual({ + apiProvider: undefined, + apiUrl: undefined, + defaultModel: undefined, + }); + }); +}); + +describe('getRequestBody', () => { + const alertsIndexPattern = 'test-index-pattern'; + const anonymizationFields = { + page: 1, + perPage: 10, + total: 100, + data: [ + { + id: '1', + field: 'field1', + }, + { + id: '2', + field: 'field2', + }, + ], + }; + const knowledgeBase = { + isEnabledKnowledgeBase: true, + isEnabledRAGAlerts: true, + latestAlerts: 20, + }; + const traceOptions = { + apmUrl: '/app/apm', + langSmithProject: '', + langSmithApiKey: '', + }; + + it('returns the expected AttackDiscoveryPostRequestBody', () => { + const result = getRequestBody({ + alertsIndexPattern, + anonymizationFields, + knowledgeBase, + traceOptions, + }); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: anonymizationFields.data, + apiConfig: { + actionTypeId: '', + connectorId: '', + model: undefined, + provider: undefined, + }, + langSmithProject: undefined, + langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, + replacements: {}, + subAction: 'invokeAI', + }); + }); + + it('returns the expected AttackDiscoveryPostRequestBody when alertsIndexPattern is undefined', () => { + const result = getRequestBody({ + alertsIndexPattern: undefined, + anonymizationFields, + knowledgeBase, + traceOptions, + }); + + expect(result).toEqual({ + alertsIndexPattern: '', + anonymizationFields: anonymizationFields.data, + apiConfig: { + actionTypeId: '', + connectorId: '', + model: undefined, + provider: undefined, + }, + langSmithProject: undefined, + langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, + replacements: {}, + subAction: 'invokeAI', + }); + }); + + it('returns the expected AttackDiscoveryPostRequestBody when LangSmith details are provided', () => { + const withLangSmith = { + alertsIndexPattern, + anonymizationFields, + knowledgeBase, + traceOptions: { + apmUrl: '/app/apm', + langSmithProject: 'A project', + langSmithApiKey: 'an API key', + }, + }; + + const result = getRequestBody(withLangSmith); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: anonymizationFields.data, + apiConfig: { + actionTypeId: '', + connectorId: '', + model: undefined, + provider: undefined, + }, + langSmithApiKey: withLangSmith.traceOptions.langSmithApiKey, + langSmithProject: withLangSmith.traceOptions.langSmithProject, + size: knowledgeBase.latestAlerts, + replacements: {}, + subAction: 'invokeAI', + }); + }); + + it('returns the expected AttackDiscoveryPostRequestBody with the expected apiConfig when selectedConnector is provided', () => { + const result = getRequestBody({ + alertsIndexPattern, + anonymizationFields, + knowledgeBase, + selectedConnector: connector, // <-- selectedConnector is provided + traceOptions, + }); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: anonymizationFields.data, + apiConfig: { + actionTypeId: connector.actionTypeId, + connectorId: connector.id, + model: undefined, + provider: undefined, + }, + langSmithProject: undefined, + langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, + replacements: {}, + subAction: 'invokeAI', + }); + }); + + it('returns the expected AttackDiscoveryPostRequestBody with the expected apiConfig when genAiConfig is provided', () => { + const genAiConfig = { + apiProvider: OpenAiProviderType.AzureAi, + defaultModel: '2024-02-15-preview', + }; + + const result = getRequestBody({ + alertsIndexPattern, + anonymizationFields, + genAiConfig, // <-- genAiConfig is provided + knowledgeBase, + selectedConnector: connector, // <-- selectedConnector is provided + traceOptions, + }); + + expect(result).toEqual({ + alertsIndexPattern, + anonymizationFields: anonymizationFields.data, + apiConfig: { + actionTypeId: connector.actionTypeId, + connectorId: connector.id, + model: genAiConfig.defaultModel, + provider: genAiConfig.apiProvider, + }, + langSmithProject: undefined, + langSmithApiKey: undefined, + size: knowledgeBase.latestAlerts, + replacements: {}, + subAction: 'invokeAI', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts new file mode 100644 index 00000000000000..6b7526870eb9f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; + +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'; + +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 { + getOpenAndAcknowledgedAlertsQuery: jest.fn(() => original), + }; +}); + +describe('getAnonymizedAlerts', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const mockAnonymizationFields = [ + { + id: '9f95b649-f20e-4edf-bd76-1d21ab6f8e2e', + timestamp: '2024-05-06T22:16:48.489Z', + field: '_id', + allowed: true, + anonymized: false, + createdAt: '2024-05-06T22:16:48.489Z', + namespace: 'default', + }, + { + id: '22f23471-4f6a-4cec-9b2a-cf270ffb53d5', + timestamp: '2024-05-06T22:16:48.489Z', + field: 'host.name', + allowed: true, + anonymized: true, + createdAt: '2024-05-06T22:16:48.489Z', + namespace: 'default', + }, + ]; + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + const mockReplacements = { + replacement1: 'SRVMAC08', + replacement2: 'SRVWIN01', + replacement3: 'SRVWIN02', + }; + const size = 10; + + beforeEach(() => { + jest.clearAllMocks(); + + (mockEsClient.search as unknown as jest.Mock).mockResolvedValue( + mockOpenAndAcknowledgedAlertsQueryResults + ); + }); + + it('returns an empty array when alertsIndexPattern is not provided', async () => { + const result = await getAnonymizedAlerts({ + esClient: mockEsClient, + size, + }); + + expect(result).toEqual([]); + }); + + it('should return an empty array when size is not provided', async () => { + const result = await getAnonymizedAlerts({ + alertsIndexPattern, + esClient: mockEsClient, + }); + + expect(result).toEqual([]); + }); + + it('should return an empty array when size is out of range', async () => { + const outOfRange = MIN_SIZE - 1; + + const result = await getAnonymizedAlerts({ + alertsIndexPattern, + esClient: mockEsClient, + size: outOfRange, + }); + + expect(result).toEqual([]); + }); + + it('calls getOpenAndAcknowledgedAlertsQuery with the provided anonymizationFields', async () => { + await getAnonymizedAlerts({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + expect(getOpenAndAcknowledgedAlertsQuery).toHaveBeenCalledWith({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + size, + }); + }); + + it('calls getOpenAndAcknowledgedAlertsQuery with empty anonymizationFields when they are NOT provided', async () => { + await getAnonymizedAlerts({ + alertsIndexPattern, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + expect(getOpenAndAcknowledgedAlertsQuery).toHaveBeenCalledWith({ + alertsIndexPattern, + anonymizationFields: [], + size, + }); + }); + + it('returns the expected transformed (anonymized) raw data', async () => { + const result = await getAnonymizedAlerts({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + expect(result).toEqual([ + '_id,b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560\nhost.name,replacement1', + '_id,0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367\nhost.name,replacement1', + '_id,600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a\nhost.name,replacement1', + '_id,e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c\nhost.name,replacement1', + '_id,2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f\nhost.name,replacement1', + '_id,2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69\nhost.name,replacement1', + '_id,4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd\nhost.name,replacement1', + '_id,449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db\nhost.name,replacement1', + '_id,f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014\nhost.name,replacement3', + '_id,aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4\nhost.name,replacement3', + '_id,dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515\nhost.name,replacement3', + '_id,f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a\nhost.name,replacement3', + '_id,6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66\nhost.name,replacement3', + '_id,ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316\nhost.name,replacement3', + '_id,0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f\nhost.name,replacement3', + '_id,b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1\nhost.name,replacement3', + '_id,7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe\nhost.name,replacement3', + '_id,ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e\nhost.name,replacement3', + '_id,cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b\nhost.name,replacement2', + '_id,6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3\nhost.name,replacement2', + ]); + }); + + it('calls onNewReplacements for every alert', async () => { + const onNewReplacements = jest.fn(); + + await getAnonymizedAlerts({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + onNewReplacements, + replacements: mockReplacements, + size, + }); + + expect(onNewReplacements).toHaveBeenCalledTimes(20); // 20 alerts in mockOpenAndAcknowledgedAlertsQueryResults + }); +}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts index 933a7ab55b9249..5989caf439518c 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts @@ -28,7 +28,7 @@ export const getAnonymizedAlerts = async ({ onNewReplacements?: (replacements: Replacements) => void; replacements?: Replacements; size?: number; -}) => { +}): Promise<string[]> => { if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { return []; } diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts new file mode 100644 index 00000000000000..bc290bf1723824 --- /dev/null +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.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 { getAttackDiscoveryPrompt } from './get_attack_discovery_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. 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: + +""" +Alert 1 + +Alert 2 + +Alert 3 +""" +`; + + const prompt = getAttackDiscoveryPrompt({ anonymizedAlerts }); + + expect(prompt).toEqual(expected); + }); +}); 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 00000000000000..446611f87ea6ac --- /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 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 }}\"},\"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/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/helpers.ts deleted file mode 100644 index fd5d4cc668df86..00000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/helpers.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. - */ - -import type { Replacements } from '@kbn/elastic-assistant-common'; - -export const getReplacementsRecords = ( - replacements: Array<{ value: string; uuid: string }> -): Replacements => - replacements.reduce<Record<string, string>>( - (acc, { value, uuid }) => ({ ...acc, [uuid]: value }), - {} - ); - -export const getReplacementsArray = ( - replacements: Replacements -): Array<{ value: string; uuid: string }> => - Object.entries(replacements).map(([uuid, value]) => ({ uuid, value }));