Skip to content

Commit

Permalink
[Security Solution][Endpoint][Host Isolation] Send case ids from UI t…
Browse files Browse the repository at this point in the history
…o isolate api (#99484)
  • Loading branch information
parkiino authored May 13, 2021
1 parent 6319544 commit 833e13f
Show file tree
Hide file tree
Showing 12 changed files with 235 additions and 27 deletions.
5 changes: 5 additions & 0 deletions x-pack/plugins/cases/common/api/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
({
Expand Down Expand Up @@ -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();
Expand All @@ -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 (
<li>
<CaseDetailsLink detailName={id}>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.placeholderCase"
defaultMessage="Case {caseIndex}"
values={{ caseIndex: index + 1 }}
/>
</CaseDetailsLink>
</li>
);
}),
[caseIds]
);

const caseCount: number = useMemo(() => caseIds.length, [caseIds]);

const hostIsolated = useMemo(() => {
return (
Expand All @@ -92,20 +117,13 @@ export const HostIsolationPanel = React.memo(
<p>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.successfulIsolation.cases"
defaultMessage="This case has been attached to the following {caseCount, plural, one {case} other {cases}}:"
defaultMessage="This action has been attached to the following {caseCount, plural, one {case} other {cases}}:"
values={{ caseCount }}
/>
</p>
</EuiText>
<EuiText size="s">
<ul>
<li>
<FormattedMessage
id="xpack.securitySolution.endpoint.hostIsolation.placeholderCase"
defaultMessage="Case"
/>
</li>
</ul>
<ul>{casesList}</ul>
</EuiText>
</>
)}
Expand All @@ -121,7 +139,7 @@ export const HostIsolationPanel = React.memo(
</EuiFlexGroup>
</>
);
}, [backToAlertDetails, hostName]);
}, [backToAlertDetails, hostName, caseCount, casesList]);

const hostNotIsolated = useMemo(() => {
return (
Expand All @@ -137,7 +155,7 @@ export const HostIsolationPanel = React.memo(
cases: (
<b>
{caseCount}
{CASES_ASSOCIATED_WITH_ALERT}
{CASES_ASSOCIATED_WITH_ALERT(caseCount)}
{alertRule}
</b>
),
Expand Down Expand Up @@ -171,7 +189,15 @@ export const HostIsolationPanel = React.memo(
</EuiFlexGroup>
</>
);
}, [alertRule, backToAlertDetails, comment, confirmHostIsolation, hostName, loading]);
}, [
alertRule,
backToAlertDetails,
comment,
confirmHostIsolation,
hostName,
loading,
caseCount,
]);

return isIsolated ? hostIsolated : hostNotIsolated;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Hit, Aggregations>({
query,
Expand All @@ -22,3 +29,9 @@ export const getUserPrivilege = async ({ signal }: BasicSignals): Promise<Privil

export const createSignalIndex = async ({ signal }: BasicSignals): Promise<AlertsIndex> =>
Promise.resolve(mockSignalIndex);

export const getCaseIdsFromAlertId = async ({
alertId,
}: {
alertId: string;
}): Promise<CasesFromAlertsResponse> => Promise.resolve(mockCaseIdsFromAlertId);
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
mockStatusAlertQuery,
mockSignalIndex,
mockUserPrivilege,
mockHostIsolation,
} from './mock';
import {
fetchQueryAlerts,
updateAlertStatus,
getSignalIndex,
getUserPrivilege,
createSignalIndex,
createHostIsolation,
} from './api';

const abortCtrl = new AbortController();
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,7 @@ import {
AlertSearchResponse,
AlertsIndex,
UpdateAlertStatusProps,
CasesFromAlertsResponse,
} from './types';

/**
Expand Down Expand Up @@ -109,20 +111,38 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise<Alert
*
* @param agent id
* @param optional comment for the isolation action
* @param optional case ids if associated with an alert on the host
*
* @throws An error if response is not OK
*/
export const createHostIsolation = async ({
agentId,
comment = '',
caseIds,
}: {
agentId: string;
comment?: string;
caseIds?: string[];
}): Promise<HostIsolationResponse> =>
KibanaServices.get().http.fetch<HostIsolationResponse>(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<CasesFromAlertsResponse> =>
KibanaServices.get().http.fetch<CasesFromAlertsResponse>(getCasesFromAlertsUrl(alertId), {
method: 'get',
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, unknown> = {
took: 7,
Expand Down Expand Up @@ -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',
];
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
);
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface AlertsIndex {
index_mapping_outdated: boolean;
}

export type CasesFromAlertsResponse = string[];

export interface Privilege {
username: string;
has_all_requested: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useAppToastsMock.create>>;
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,
});
});
});
Loading

0 comments on commit 833e13f

Please sign in to comment.