From 833e13faff59b99f24cd793b932081d6cfc9d879 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 13 May 2021 09:37:56 -0400 Subject: [PATCH] [Security Solution][Endpoint][Host Isolation] Send case ids from UI to isolate api (#99484) --- x-pack/plugins/cases/common/api/helpers.ts | 5 ++ .../components/host_isolation/index.tsx | 58 ++++++++++++++----- .../components/host_isolation/translations.ts | 14 +++-- .../detection_engine/alerts/__mocks__/api.ts | 17 +++++- .../detection_engine/alerts/api.test.ts | 31 ++++++++++ .../containers/detection_engine/alerts/api.ts | 20 +++++++ .../detection_engine/alerts/mock.ts | 12 +++- .../detection_engine/alerts/translations.ts | 5 ++ .../detection_engine/alerts/types.ts | 2 + .../alerts/use_cases_from_alerts.test.tsx | 41 +++++++++++++ .../alerts/use_cases_from_alerts.tsx | 51 ++++++++++++++++ .../alerts/use_host_isolation.tsx | 6 +- 12 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 43e292b91db4b..7ac686ce5c8dd 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -14,6 +14,7 @@ import { SUB_CASES_URL, CASE_PUSH_URL, SUB_CASE_USER_ACTIONS_URL, + CASE_ALERTS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -47,3 +48,7 @@ export const getSubCaseUserActionUrl = (caseID: string, subCaseId: string): stri export const getCasePushUrl = (caseId: string, connectorId: string): string => { return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); }; + +export const getCasesFromAlertsUrl = (alertId: string): string => { + return CASE_ALERTS_URL.replace('{alert_id}', alertId); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index 30ee7e77f3a7d..3897458e8459c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -21,7 +21,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { CANCEL, CASES_ASSOCIATED_WITH_ALERT, @@ -31,6 +30,9 @@ import { RETURN_TO_ALERT_DETAILS, } from './translations'; import { Maybe } from '../../../../../observability/common/typings'; +import { useCasesFromAlerts } from '../../containers/detection_engine/alerts/use_cases_from_alerts'; +import { CaseDetailsLink } from '../../../common/components/links'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; export const HostIsolationPanel = React.memo( ({ @@ -59,7 +61,13 @@ export const HostIsolationPanel = React.memo( return findAlertRule ? findAlertRule[0] : ''; }, [details]); - const { loading, isolateHost } = useHostIsolation({ agentId, comment }); + const alertId = useMemo(() => { + const findAlertId = find({ category: '_id', field: '_id' }, details)?.values; + return findAlertId ? findAlertId[0] : ''; + }, [details]); + + const { caseIds } = useCasesFromAlerts({ alertId }); + const { loading, isolateHost } = useHostIsolation({ agentId, comment, caseIds }); const confirmHostIsolation = useCallback(async () => { const hostIsolated = await isolateHost(); @@ -68,8 +76,25 @@ export const HostIsolationPanel = React.memo( const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); - // a placeholder until we get the case count returned from a new case route in a future pr - const caseCount: number = 0; + const casesList = useMemo( + () => + caseIds.map((id, index) => { + return ( +
  • + + + +
  • + ); + }), + [caseIds] + ); + + const caseCount: number = useMemo(() => caseIds.length, [caseIds]); const hostIsolated = useMemo(() => { return ( @@ -92,20 +117,13 @@ export const HostIsolationPanel = React.memo(

    - + )} @@ -121,7 +139,7 @@ export const HostIsolationPanel = React.memo( ); - }, [backToAlertDetails, hostName]); + }, [backToAlertDetails, hostName, caseCount, casesList]); const hostNotIsolated = useMemo(() => { return ( @@ -137,7 +155,7 @@ export const HostIsolationPanel = React.memo( cases: ( {caseCount} - {CASES_ASSOCIATED_WITH_ALERT} + {CASES_ASSOCIATED_WITH_ALERT(caseCount)} {alertRule} ), @@ -171,7 +189,15 @@ export const HostIsolationPanel = React.memo( ); - }, [alertRule, backToAlertDetails, comment, confirmHostIsolation, hostName, loading]); + }, [ + alertRule, + backToAlertDetails, + comment, + confirmHostIsolation, + hostName, + loading, + caseCount, + ]); return isIsolated ? hostIsolated : hostNotIsolated; } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 97a1a278952a6..8d6334f6c340d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -31,12 +31,14 @@ export const CONFIRM = i18n.translate('xpack.securitySolution.endpoint.hostIsola defaultMessage: 'Confirm', }); -export const CASES_ASSOCIATED_WITH_ALERT = i18n.translate( - 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWihtAlert', - { - defaultMessage: ' cases associated with the rule ', - } -); +export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => + i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', + { + defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ', + values: { caseCount }, + } + ); export const RETURN_TO_ALERT_DETAILS = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.returnToAlertDetails', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts index e1f5b53e2f4c3..ea64f39226cd2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts @@ -5,8 +5,15 @@ * 2.0. */ -import { QueryAlerts, AlertSearchResponse, BasicSignals, AlertsIndex, Privilege } from '../types'; -import { alertsMock, mockSignalIndex, mockUserPrivilege } from '../mock'; +import { + QueryAlerts, + AlertSearchResponse, + BasicSignals, + AlertsIndex, + Privilege, + CasesFromAlertsResponse, +} from '../types'; +import { alertsMock, mockSignalIndex, mockUserPrivilege, mockCaseIdsFromAlertId } from '../mock'; export const fetchQueryAlerts = async ({ query, @@ -22,3 +29,9 @@ export const getUserPrivilege = async ({ signal }: BasicSignals): Promise => Promise.resolve(mockSignalIndex); + +export const getCaseIdsFromAlertId = async ({ + alertId, +}: { + alertId: string; +}): Promise => Promise.resolve(mockCaseIdsFromAlertId); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 82f275f7dc9ba..9aa5cfd229292 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -12,6 +12,7 @@ import { mockStatusAlertQuery, mockSignalIndex, mockUserPrivilege, + mockHostIsolation, } from './mock'; import { fetchQueryAlerts, @@ -19,6 +20,7 @@ import { getSignalIndex, getUserPrivilege, createSignalIndex, + createHostIsolation, } from './api'; const abortCtrl = new AbortController(); @@ -163,4 +165,33 @@ describe('Detections Alerts API', () => { expect(alertsResp).toEqual(mockSignalIndex); }); }); + + describe('createHostIsolation', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(mockHostIsolation); + }); + + test('check parameter url', async () => { + await createHostIsolation({ + agentId: 'fd8a122b-4c54-4c05-b295-e5f8381fc59d', + comment: 'commento', + caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'], + }); + expect(fetchMock).toHaveBeenCalledWith('/api/endpoint/isolate', { + method: 'POST', + body: + '{"agent_ids":["fd8a122b-4c54-4c05-b295-e5f8381fc59d"],"comment":"commento","case_ids":["88c04a90-b19c-11eb-b838-bf3c7840b969"]}', + }); + }); + + test('happy path', async () => { + const hostIsolationResponse = await createHostIsolation({ + agentId: 'fd8a122b-4c54-4c05-b295-e5f8381fc59d', + comment: 'commento', + caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'], + }); + expect(hostIsolationResponse).toEqual(mockHostIsolation); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index dbcb11383432f..300005b23caaa 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -6,6 +6,7 @@ */ import { UpdateDocumentByQueryResponse } from 'elasticsearch'; +import { getCasesFromAlertsUrl } from '../../../../../../cases/common'; import { HostIsolationResponse } from '../../../../../common/endpoint/types'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL, @@ -22,6 +23,7 @@ import { AlertSearchResponse, AlertsIndex, UpdateAlertStatusProps, + CasesFromAlertsResponse, } from './types'; /** @@ -109,20 +111,38 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise => KibanaServices.get().http.fetch(ISOLATE_HOST_ROUTE, { method: 'POST', body: JSON.stringify({ agent_ids: [agentId], comment, + case_ids: caseIds, }), }); + +/** + * Get list of associated case ids from alert id + * + * @param alert id + */ +export const getCaseIdsFromAlertId = async ({ + alertId, +}: { + alertId: string; +}): Promise => + KibanaServices.get().http.fetch(getCasesFromAlertsUrl(alertId), { + method: 'get', + }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 18651063df8ca..69358958a395c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { AlertSearchResponse, AlertsIndex, Privilege } from './types'; +import { HostIsolationResponse } from '../../../../../common/endpoint/types/actions'; +import { AlertSearchResponse, AlertsIndex, Privilege, CasesFromAlertsResponse } from './types'; export const alertsMock: AlertSearchResponse = { took: 7, @@ -1039,3 +1040,12 @@ export const mockUserPrivilege: Privilege = { is_authenticated: true, has_encryption_key: true, }; + +export const mockHostIsolation: HostIsolationResponse = { + action: '713085d6-ab45-4e9e-b41d-96563cafdd97', +}; + +export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ + '818601a0-b26b-11eb-8759-6b318e8cf4bc', + '8a774850-b26b-11eb-8759-6b318e8cf4bc', +]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index 2998c97376c26..ed6a22375a776 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -32,3 +32,8 @@ export const HOST_ISOLATION_FAILURE = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.failedToIsolate.title', { defaultMessage: 'Failed to isolate host' } ); + +export const CASES_FROM_ALERTS_FAILURE = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title', + { defaultMessage: 'Failed to find associated cases' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 26108ca939a57..52b477d95076b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -48,6 +48,8 @@ export interface AlertsIndex { index_mapping_outdated: boolean; } +export type CasesFromAlertsResponse = string[]; + export interface Privilege { username: string; has_all_requested: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx new file mode 100644 index 0000000000000..0867fb001051a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx @@ -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. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useCasesFromAlerts } from './use_cases_from_alerts'; +import * as api from './api'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { mockCaseIdsFromAlertId } from './mock'; + +jest.mock('./api'); +jest.mock('../../../../common/hooks/use_app_toasts'); + +describe('useCasesFromAlerts hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.resetAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns an array of caseIds', async () => { + const spyOnCases = jest.spyOn(api, 'getCaseIdsFromAlertId'); + const { result, waitForNextUpdate } = renderHook(() => + useCasesFromAlerts({ alertId: 'anAlertId' }) + ); + await waitForNextUpdate(); + expect(spyOnCases).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + caseIds: mockCaseIdsFromAlertId, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx new file mode 100644 index 0000000000000..fb130eb744700 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.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 { isEmpty } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { getCaseIdsFromAlertId } from './api'; +import { CASES_FROM_ALERTS_FAILURE } from './translations'; +import { CasesFromAlertsResponse } from './types'; + +interface CasesFromAlertsStatus { + loading: boolean; + caseIds: CasesFromAlertsResponse; +} + +export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromAlertsStatus => { + const [loading, setLoading] = useState(false); + const [cases, setCases] = useState([]); + const { addError } = useAppToasts(); + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const casesResponse = await getCaseIdsFromAlertId({ alertId }); + if (isMounted) { + setCases(casesResponse); + } + } catch (error) { + addError(error.message, { title: CASES_FROM_ALERTS_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + if (!isEmpty(alertId)) { + fetchData(); + } + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [alertId, addError]); + return { loading, caseIds: cases }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx index 684bc6af5d2c7..ad3c6e91c03fe 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx @@ -18,11 +18,13 @@ interface HostIsolationStatus { interface UseHostIsolationProps { agentId: string; comment: string; + caseIds?: string[]; } export const useHostIsolation = ({ agentId, comment, + caseIds, }: UseHostIsolationProps): HostIsolationStatus => { const [loading, setLoading] = useState(false); const { addError } = useAppToasts(); @@ -30,7 +32,7 @@ export const useHostIsolation = ({ const isolateHost = useCallback(async () => { try { setLoading(true); - const isolationStatus = await createHostIsolation({ agentId, comment }); + const isolationStatus = await createHostIsolation({ agentId, comment, caseIds }); setLoading(false); return isolationStatus.action ? true : false; } catch (error) { @@ -38,6 +40,6 @@ export const useHostIsolation = ({ addError(error.message, { title: HOST_ISOLATION_FAILURE }); return false; } - }, [agentId, comment, addError]); + }, [agentId, comment, caseIds, addError]); return { loading, isolateHost }; };