From 199af3c580a30a301ecf515e4d92e47436322ed4 Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Mon, 8 May 2023 21:58:02 -0600 Subject: [PATCH] [Security Solution] Security Assistant: useSecurityAssistantQuery hook and New chat button #5 - adds the useSecurityAssistantQuery hook - adds a `New chat` button that renders a query in a popover --- .../event_details/alert_summary_view.tsx | 8 +- .../event_details/summary_view.test.tsx | 10 +- .../components/event_details/summary_view.tsx | 6 +- .../public/security_assistant/helpers.ts | 41 ++++ .../security_assistant/new_chat/index.tsx | 68 +++++++ .../new_chat/translations.ts | 19 ++ .../security_assistant/security_assistant.tsx | 178 +++++++++--------- .../use_security_assistant_query/helpers.ts | 69 +++++++ .../use_security_assistant_query/index.tsx | 46 +++++ 9 files changed, 352 insertions(+), 93 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/security_assistant/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/security_assistant/new_chat/index.tsx create mode 100644 x-pack/plugins/security_solution/public/security_assistant/new_chat/translations.ts create mode 100644 x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/index.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index 03327cea9ba1e..d5aa4302a593b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -30,7 +30,13 @@ const AlertSummaryViewComponent: React.FC<{ ); return ( - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx index 7fac3083e0b8b..1f61b95cf00f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx @@ -66,7 +66,7 @@ describe('Summary View', () => { test('should show an empty table', () => { render( - + ); expect(screen.getByText('No items found')).toBeInTheDocument(); @@ -84,7 +84,12 @@ describe('Summary View', () => { render( - + ); // Shows the field name @@ -113,6 +118,7 @@ describe('Summary View', () => { render( void; title: string; rows: AlertSummaryRow[]; isReadOnly?: boolean; -}> = ({ goToTable, rows, title, isReadOnly }) => { +}> = ({ data, goToTable, rows, title, isReadOnly }) => { const columns = isReadOnly ? baseColumns : allColumns; return (
+ diff --git a/x-pack/plugins/security_solution/public/security_assistant/helpers.ts b/x-pack/plugins/security_solution/public/security_assistant/helpers.ts new file mode 100644 index 0000000000000..167e51bab910b --- /dev/null +++ b/x-pack/plugins/security_solution/public/security_assistant/helpers.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface SecurityAssistantUiSettings { + virusTotal: { + apiKey: string; + baseUrl: string; + }; + + openAI: { + apiKey: string; + baseUrl: string; + }; +} + +export async function fetchVirusTotalReport({ + hash, + settings: { virusTotal, openAI }, +}: { + hash: string; + settings: SecurityAssistantUiSettings; +}): Promise { + const url = `${virusTotal.baseUrl}/files/${hash}`; + + const response = await fetch(url, { + headers: { + 'x-apikey': virusTotal.apiKey, + }, + }); + + if (!response.ok) { + throw new Error(`VirusTotal API request failed with status ${response.status}`); + } + + const data = await response.json(); + return data; +} diff --git a/x-pack/plugins/security_solution/public/security_assistant/new_chat/index.tsx b/x-pack/plugins/security_solution/public/security_assistant/new_chat/index.tsx new file mode 100644 index 0000000000000..b9e9d77cfb91a --- /dev/null +++ b/x-pack/plugins/security_solution/public/security_assistant/new_chat/index.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { useToasts } from '../../common/lib/kibana'; +import type { TimelineEventsDetailsItem } from '../../../common/search_strategy'; +import { SecurityAssistant } from '../security_assistant'; +import * as i18n from './translations'; +import { useSecurityAssistantQuery } from '../use_security_assistant_query'; + +const SecurityAssistantContainer = styled.div` + max-height: 1020px; + max-width: 600px; +`; + +const NewChatComponent: React.FC<{ + data: TimelineEventsDetailsItem[]; +}> = ({ data }) => { + const toasts = useToasts(); + const [query, setQuery] = useState(''); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = () => setIsPopoverOpen(false); + + const { getQuery } = useSecurityAssistantQuery({ data }); + + const onStartConversation = useCallback(async () => { + try { + setQuery(await getQuery()); + setIsPopoverOpen((isOpen) => !isOpen); + } catch (error) { + toasts.addError(error, { title: i18n.ERROR_FETCHING_SECURITY_ASSISTANT_QUERY }); + } + }, [getQuery, toasts]); + + const NewChatButton = useMemo( + () => ( + + {i18n.NEW_CHAT} + + ), + [onStartConversation] + ); + + return ( + + + + + + ); +}; + +NewChatComponent.displayName = 'NewChatComponent'; + +export const NewChat = React.memo(NewChatComponent); diff --git a/x-pack/plugins/security_solution/public/security_assistant/new_chat/translations.ts b/x-pack/plugins/security_solution/public/security_assistant/new_chat/translations.ts new file mode 100644 index 0000000000000..a774c224b7021 --- /dev/null +++ b/x-pack/plugins/security_solution/public/security_assistant/new_chat/translations.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NEW_CHAT = i18n.translate('xpack.securitySolution.securityAssistant.newChatButton', { + defaultMessage: 'New chat', +}); + +export const ERROR_FETCHING_SECURITY_ASSISTANT_QUERY = i18n.translate( + 'xpack.securitySolution.securityAssistant.errorFetchingSecurityAssistantQuery', + { + defaultMessage: 'Error fetching security assistant query', + } +); diff --git a/x-pack/plugins/security_solution/public/security_assistant/security_assistant.tsx b/x-pack/plugins/security_solution/public/security_assistant/security_assistant.tsx index c0b2f3f514a3f..d343b5c4234b1 100644 --- a/x-pack/plugins/security_solution/public/security_assistant/security_assistant.tsx +++ b/x-pack/plugins/security_solution/public/security_assistant/security_assistant.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import type { EuiCommentProps } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, @@ -22,37 +23,37 @@ import { EuiAvatar, EuiPageHeader, EuiFilePicker, - EuiCommentProps, EuiMarkdownFormat, EuiIcon, } from '@elastic/eui'; import crypto from 'crypto'; -import { DataProvider } from '@kbn/timelines-plugin/common'; -import { SendToTimelineButton } from './send_to_timeline_button'; -import { useKibana } from '../../public/common/lib/kibana'; +import type { DataProvider } from '@kbn/timelines-plugin/common'; import { CommentType } from '@kbn/cases-plugin/common'; +import styled from 'styled-components'; + import { fetchOpenAlerts, fetchVirusTotalAnalysis, sendFileToVirusTotal, sendMessage } from './api'; +import { useKibana } from '../common/lib/kibana'; +import type { SecurityAssistantUiSettings } from './helpers'; +import { fetchVirusTotalReport } from './helpers'; +import { SendToTimelineButton } from './send_to_timeline_button'; + +const CommentsContainer = styled.div` + max-height: 600px; + overflow-y: scroll; +`; export const SECURITY_ASSISTANT_UI_SETTING_KEY = 'securityAssistant'; -export interface SecurityAssistantUiSettings { - virusTotal: { - apiKey: string; - baseUrl: string; - }; - openAI: { - apiKey: string; - baseUrl: string; - }; -} export interface SecurityAssistantProps { + input?: string; useLocalStorage?: boolean; } export const SecurityAssistant: React.FC = - React.memo(() => { + React.memo(({ input = '' }) => { + const bottomRef = useRef(null); const { uiSettings } = useKibana().services; - const [inputText, setInputText] = useState(''); + const [inputText, setInputText] = useState(input); const [lastResponse, setLastResponse] = useState(''); const [chatHistory, setChatHistory] = useState< Array<{ @@ -67,7 +68,7 @@ export const SecurityAssistant: React.FC = SECURITY_ASSISTANT_UI_SETTING_KEY ); - //// New code from Garrett for attach to case action + // New code from Garrett for attach to case action // Attach to case support const { cases } = useKibana().services; const selectCaseModal = cases.hooks.useCasesAddToExistingCaseModal({ @@ -149,7 +150,7 @@ export const SecurityAssistant: React.FC = console.log(response); const stats = response.data.attributes.stats; - //const links = response.data.attributes.links; + // const links = response.data.attributes.links; const result = `**VirusTotal analysis results for \`${sha256Hash}\`**:\n\n` + `- Malicious: ${stats.malicious}\n` + @@ -188,35 +189,19 @@ export const SecurityAssistant: React.FC = const alertName = _source['kibana.alert.rule.name']; const severity = _source['kibana.alert.severity']; const reason = _source['kibana.alert.reason']; - const user = _source['user']; - const host = _source['host']; + const user = _source.user; + const host = _source.host; - const user_risk = user && user['risk'] ? user['risk']['calculated_level'] : 'N/A'; - const host_risk = host && host['risk'] ? host['risk']['calculated_level'] : 'N/A'; + const userRisk = user && user.risk ? user.risk.calculated_level : 'N/A'; + const hostRisk = host && host.risk ? host.risk.calculated_level : 'N/A'; formattedAlerts += `| ${ index + 1 - } | ${alertName} | ${severity} | ${reason} | ${user_risk} | ${host_risk} |\n`; + } | ${alertName} | ${severity} | ${reason} | ${userRisk} | ${hostRisk} |\n`; }); return formattedAlerts; } - async function fetchVirusTotalReport(hash: string): Promise { - const url = `${virusTotal.baseUrl}/files/${hash}`; - - const response = await fetch(url, { - headers: { - 'x-apikey': virusTotal.apiKey, - }, - }); - - if (!response.ok) { - throw new Error(`VirusTotal API request failed with status ${response.status}`); - } - - const data = await response.json(); - return data; - } const handleInputChange = (event: React.ChangeEvent) => { setInputText(event.target.value); }; @@ -281,7 +266,18 @@ export const SecurityAssistant: React.FC = } setIsLoading(false); - }, [inputText, chatHistory, openAI.apiKey, openAI.baseUrl]); + }, [inputText, chatHistory, dateTimeString, handleOpenAlerts, openAI.baseUrl, openAI.apiKey]); + + useEffect(() => { + if (chatHistory.length === 0) { + sendMessageLocal(); + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [chatHistory.length, input, sendMessageLocal]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [lastResponse]); const clearChat = () => { setChatHistory([]); @@ -298,7 +294,7 @@ export const SecurityAssistant: React.FC = fileReader.onload = async (event) => { if (event.target && event.target.result) { const fileContent = event.target.result as ArrayBuffer; - //const base64File = btoa(String.fromCharCode(...new Uint8Array(fileContent))); + // const base64File = btoa(String.fromCharCode(...new Uint8Array(fileContent))); // Calculate the SHA-256 hash const hash = crypto.createHash('sha256'); @@ -364,7 +360,7 @@ export const SecurityAssistant: React.FC = fileReader.readAsArrayBuffer(file); }; - //// New Code from Garrett for Add To Timeline action + // New Code from Garrett for Add To Timeline action // Grab all relevant dom elements const commentBlocks = [...document.getElementsByClassName('euiMarkdownFormat')]; // Filter if no code block exists as to not make extra portals @@ -374,7 +370,7 @@ export const SecurityAssistant: React.FC = chatHistory.length > 0 ? commentBlocks.map((commentBlock) => { return { - commentBlock: commentBlock, + commentBlock, codeBlocks: [...commentBlock.querySelectorAll('.euiCodeBlock__code')], codeBlockControls: [...commentBlock.querySelectorAll('.euiCodeBlock__controls')], }; @@ -436,52 +432,56 @@ export const SecurityAssistant: React.FC = }); } })} - { - const isUser = message.role === 'user'; - const commentProps: EuiCommentProps = { - username: isUser ? 'You' : 'Assistant', - actions: ( - <> - handleAddToExistingCaseClick(message.content)} - iconType="addDataApp" - color="primary" - aria-label="Add to existing case" + + + { + const isUser = message.role === 'user'; + const commentProps: EuiCommentProps = { + username: isUser ? 'You' : 'Assistant', + actions: ( + <> + handleAddToExistingCaseClick(message.content)} + iconType="addDataApp" + color="primary" + aria-label="Add to existing case" + /> + + {(copy) => ( + + )} + + + ), + // event: isUser ? 'Asked a question' : 'Responded with', + children: ( + + {message.content} + + ), + timelineAvatar: isUser ? ( + + ) : ( + - - {(copy) => ( - - )} - - - ), - //event: isUser ? 'Asked a question' : 'Responded with', - children: ( - - {message.content} - - ), - timelineAvatar: isUser ? ( - - ) : ( - - ), - timestamp: 'at: ' + message.timestamp, - }; - return commentProps; - })} - /> + ), + timestamp: `at: ${message.timestamp}`, + }; + return commentProps; + })} + /> +
+ diff --git a/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/helpers.ts b/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/helpers.ts new file mode 100644 index 0000000000000..e7d25cde76fa4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/helpers.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimelineEventsDetailsItem } from '../../../common/search_strategy'; + +interface QueryField { + field: string; + values: string; +} + +export const getQueryFields = (data: TimelineEventsDetailsItem[]): QueryField[] => [ + ...data + ?.filter((x) => x.field === 'kibana.alert.rule.description') + ?.map((x) => ({ + field: 'kibana.alert.rule.description', + values: x.values?.join(',\n') ?? '', + })), + ...data + ?.filter((x) => x.field === 'event.category') + ?.map((x) => ({ field: 'event.category', values: x.values?.join(',\n') ?? '' })), + ...data + ?.filter((x) => x.field === 'event.action') + ?.map((x) => ({ field: 'event.action', values: x.values?.join(',\n') ?? '' })), + ...data + ?.filter((x) => x.field === 'host.name') + ?.map((x) => ({ field: 'host.name', values: x.values?.join(',\n') ?? '' })), + ...data + ?.filter((x) => x.field === 'kibana.alert.reason') + ?.map((x) => ({ field: 'kibana.alert.reason', values: x.values?.join(',\n') ?? '' })), + ...data + ?.filter((x) => x.field === 'destination.ip') + ?.map((x) => ({ field: 'destination.ip', values: x.values?.join(',\n') ?? '' })), + ...data + ?.filter((x) => x.field === 'user.name') + ?.map((x) => ({ field: 'user.name', values: x.values?.join(',\n') ?? '' })), +]; + +export const getFieldsAsCsv = (queryFields: QueryField[]): string => + queryFields.map(({ field, values }) => `${field},${values}`).join('\n'); + +export const getDefaultQuery = (context: string) => ` +You are a helpful, expert ai assistant who answers questions about Elastic Security. You have the personality of a mutant superhero who says "bub" a lot. +Given the following context containing the most relevant fields from an alert or event: + + +CONTEXT: +""" +${context} +""" + + +Explain the meaning from the context above, then summarize a list of suggested Elasticsearch KQL and EQL queries. Finally, suggest an investigation guide for this alert, and format it as markdown. +`; + +export async function getQueryFromEventDetails({ + data, +}: { + data: TimelineEventsDetailsItem[]; +}): Promise { + const queryFields = getQueryFields(data); + const fieldsCsv = getFieldsAsCsv(queryFields); + const query = getDefaultQuery(fieldsCsv); + + return query; +} diff --git a/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/index.tsx b/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/index.tsx new file mode 100644 index 0000000000000..6f077069e3325 --- /dev/null +++ b/x-pack/plugins/security_solution/public/security_assistant/use_security_assistant_query/index.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 { useCallback, useState } from 'react'; + +import type { TimelineEventsDetailsItem } from '../../../common/search_strategy'; +import { getQueryFromEventDetails } from './helpers'; + +export interface UseSecurityAssistantQuery { + getQuery: () => Promise; + error: string | null; + loading: boolean; +} + +export interface Props { + data: TimelineEventsDetailsItem[]; +} + +export const useSecurityAssistantQuery = ({ data }: Props): UseSecurityAssistantQuery => { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const getQuery = useCallback(async () => { + const abortController = new AbortController(); + + try { + return await getQueryFromEventDetails({ data }); + } catch (e) { + if (!abortController.signal.aborted) { + setError(e.message); + } + + throw new Error(e.message); + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }, [data]); + + return { getQuery, error, loading }; +};