Skip to content

Commit

Permalink
[EDR Workflows] Add S1 actions to Cases and refactor endpoint to use …
Browse files Browse the repository at this point in the history
…cases external reference api (elastic#175696)
  • Loading branch information
tomsonpl authored and fkanout committed Feb 7, 2024
1 parent 2fc0d72 commit c6f5418
Show file tree
Hide file tree
Showing 23 changed files with 504 additions and 185 deletions.
10 changes: 8 additions & 2 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public';
import * as i18n from './translations';

export { SecurityPageName } from '@kbn/security-solution-navigation';

/**
Expand Down Expand Up @@ -338,12 +339,12 @@ export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const;
export const UNAUTHENTICATED_USER = 'Unauthenticated' as const;

/**
Licensing requirements
Licensing requirements
*/
export const MINIMUM_ML_LICENSE = 'platinum' as const;

/**
Machine Learning constants
Machine Learning constants
*/
export const ML_GROUP_ID = 'security' as const;
export const LEGACY_ML_GROUP_ID = 'siem' as const;
Expand Down Expand Up @@ -519,3 +520,8 @@ export const DEFAULT_ALERT_TAGS_VALUE = [
* Max length for the comments within security solution
*/
export const MAX_COMMENT_LENGTH = 30000 as const;

/**
* Cases external attachment IDs
*/
export const CASE_ATTACHMENT_ENDPOINT_TYPE_ID = 'endpoint' as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiAvatar } from '@elastic/eui';
import type { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { getLazyExternalChildrenContent } from './lazy_external_reference_children_content';
import { CASE_ATTACHMENT_ENDPOINT_TYPE_ID } from '../../../common/constants';
import { getLazyExternalEventContent } from './lazy_external_reference_content';
import type { IExternalReferenceMetaDataProps } from './types';

export const getExternalReferenceAttachmentEndpointRegular =
(): ExternalReferenceAttachmentType => ({
id: CASE_ATTACHMENT_ENDPOINT_TYPE_ID,
displayName: 'Endpoint',
// @ts-expect-error: TS2322 figure out types for children lazyExotic
getAttachmentViewObject: (props: IExternalReferenceMetaDataProps) => {
const iconType = props.externalReferenceMetadata?.command === 'isolate' ? 'lock' : 'lockOpen';
return {
type: 'regular',
event: getLazyExternalEventContent(props),
timelineAvatar: (
<EuiAvatar name="endpoint" color="subdued" iconType={iconType} aria-label={iconType} />
),
children: getLazyExternalChildrenContent,
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { render } from '@testing-library/react';
import AttachmentContentChildren from './external_reference_children';

describe('AttachmentContentChildren', () => {
const defaultProps = {
command: 'isolate',
comment: 'Test comment',
targets: [
{
endpointId: 'endpoint-1',
hostname: 'host-1',
agentType: 'endpoint' as const,
},
],
};

it('renders markdown content when comment exists', () => {
const props = {
externalReferenceMetadata: {
...defaultProps,
},
};
const { getByText } = render(<AttachmentContentChildren {...props} />);
expect(getByText('Test comment')).toBeInTheDocument();
});

it('does not render when comment is empty', () => {
const props = {
externalReferenceMetadata: {
...defaultProps,
comment: '',
},
};
const { container } = render(<AttachmentContentChildren {...props} />);
expect(container.firstChild).toBeNull();
});

it('does not render when comment is only whitespace', () => {
const props = {
externalReferenceMetadata: {
...defaultProps,
comment: ' ',
},
};

const { container } = render(<AttachmentContentChildren {...props} />);
expect(container.firstChild).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';

import styled from 'styled-components';
import { EuiMarkdownFormat } from '@elastic/eui';
import type { IExternalReferenceMetaDataProps } from './types';

export const ContentWrapper = styled.div`
padding: ${({ theme }) => `${theme.eui?.euiSizeM} ${theme.eui?.euiSizeL}`};
text-overflow: ellipsis;
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
`;

const AttachmentContentChildren = ({
externalReferenceMetadata: { comment },
}: IExternalReferenceMetaDataProps) => {
return comment.trim().length > 0 ? (
<ContentWrapper>
<EuiMarkdownFormat grow={true}>{comment}</EuiMarkdownFormat>
</ContentWrapper>
) : null;
};
// eslint-disable-next-line import/no-default-export
export { AttachmentContentChildren as default };
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { render, fireEvent } from '@testing-library/react';

import AttachmentContentEvent from './external_reference_event';
import { useNavigation } from '@kbn/security-solution-navigation/src/navigation';

jest.mock('@kbn/security-solution-navigation/src/navigation', () => {
return {
useNavigation: jest.fn(),
};
});

describe('AttachmentContentEvent', () => {
const mockNavigateTo = jest.fn();

const mockUseNavigation = useNavigation as jest.Mocked<typeof useNavigation>;
(mockUseNavigation as jest.Mock).mockReturnValue({
getAppUrl: jest.fn(),
navigateTo: mockNavigateTo,
});

const defaultProps = {
externalReferenceMetadata: {
command: 'isolate',
comment: 'test comment',
targets: [
{
endpointId: 'endpoint-1',
hostname: 'host-1',
agentType: 'endpoint' as const,
},
],
},
};

it('renders the expected text based on the command', () => {
const { getByText, getByTestId, rerender } = render(
<AttachmentContentEvent {...defaultProps} />
);

expect(getByText('submitted isolate request on host')).toBeInTheDocument();
expect(getByTestId('actions-link-endpoint-1')).toHaveTextContent('host-1');

rerender(
<AttachmentContentEvent
{...defaultProps}
externalReferenceMetadata={{
...defaultProps.externalReferenceMetadata,
command: 'unisolate',
}}
/>
);

expect(getByText('submitted release request on host')).toBeInTheDocument();
expect(getByTestId('actions-link-endpoint-1')).toHaveTextContent('host-1');
});

it('navigates on link click', () => {
const { getByTestId } = render(<AttachmentContentEvent {...defaultProps} />);

fireEvent.click(getByTestId('actions-link-endpoint-1'));

expect(mockNavigateTo).toHaveBeenCalled();
});

it('builds endpoint details URL correctly', () => {
const mockGetAppUrl = jest.fn().mockReturnValue('http://app.url');
(mockUseNavigation as jest.Mock).mockReturnValue({
getAppUrl: mockGetAppUrl,
});

render(<AttachmentContentEvent {...defaultProps} />);

expect(mockGetAppUrl).toHaveBeenNthCalledWith(1, {
path: '/administration/endpoints?selected_endpoint=endpoint-1&show=activity_log',
});
expect(mockGetAppUrl).toHaveBeenNthCalledWith(2, {
path: '/hosts/name/host-1',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { EuiLink } from '@elastic/eui';
import { useNavigation } from '@kbn/security-solution-navigation/src/navigation';
import React, { useCallback, useMemo } from 'react';

import { ISOLATED_HOST, RELEASED_HOST, OTHER_ENDPOINTS } from '../pages/translations';
import type { IExternalReferenceMetaDataProps } from './types';
import { getEndpointDetailsPath } from '../../management/common/routing';

const AttachmentContentEvent = ({
externalReferenceMetadata: { command, targets },
}: IExternalReferenceMetaDataProps) => {
const { getAppUrl, navigateTo } = useNavigation();

const endpointDetailsHref = getAppUrl({
path: getEndpointDetailsPath({
name: 'endpointActivityLog',
selected_endpoint: targets[0].endpointId,
}),
});
const hostsDetailsHref = getAppUrl({
path: `/hosts/name/${targets[0].hostname}`,
});

const actionText = useMemo(() => {
return command === 'isolate' ? `${ISOLATED_HOST} ` : `${RELEASED_HOST} `;
}, [command]);

const linkHref = useMemo(
() => (targets[0].agentType === 'endpoint' ? endpointDetailsHref : hostsDetailsHref),
[endpointDetailsHref, hostsDetailsHref, targets]
);

const onLinkClick = useCallback(
(ev: React.MouseEvent<HTMLAnchorElement>) => {
ev.preventDefault();
return navigateTo({ url: linkHref });
},
[navigateTo, linkHref]
);

return (
<>
{actionText}
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
onClick={onLinkClick}
href={linkHref}
data-test-subj={`actions-link-${targets[0].endpointId}`}
>
{targets[0].hostname}
</EuiLink>
{targets.length > 1 && OTHER_ENDPOINTS(targets.length - 1)}
</>
);
};

// eslint-disable-next-line import/no-default-export
export { AttachmentContentEvent as default };
Original file line number Diff line number Diff line change
@@ -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 React, { lazy, Suspense } from 'react';
import type { IExternalReferenceMetaDataProps } from './types';

const AttachmentContent = lazy(() => import('./external_reference_children'));

export const getLazyExternalChildrenContent = (props: IExternalReferenceMetaDataProps) => {
return (
<Suspense fallback={null}>
<AttachmentContent {...props} />
</Suspense>
);
};
Original file line number Diff line number Diff line change
@@ -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 React, { lazy, Suspense } from 'react';
import type { IExternalReferenceMetaDataProps } from './types';

const AttachmentContent = lazy(() => import('./external_reference_event'));

export const getLazyExternalEventContent = (props: IExternalReferenceMetaDataProps) => {
return (
<Suspense fallback={null}>
<AttachmentContent {...props} />
</Suspense>
);
};
24 changes: 24 additions & 0 deletions x-pack/plugins/security_solution/public/cases/attachments/types.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

import type { ResponseActionAgentType } from '../../../common/endpoint/service/response_actions/constants';

export interface IExternalReferenceMetaDataProps {
externalReferenceMetadata: {
comment: ExternalReferenceCommentType;
command: ExternalReferenceCommandType;
targets: ExternalReferenceTargetsType;
};
}

type ExternalReferenceTargetsType = Array<{
endpointId: string;
hostname: string;
agentType: ResponseActionAgentType;
}>;
type ExternalReferenceCommentType = string;
type ExternalReferenceCommandType = string;
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,17 @@ import { i18n } from '@kbn/i18n';
export const PAGE_TITLE = i18n.translate('xpack.securitySolution.cases.pageTitle', {
defaultMessage: 'Cases',
});

export const ISOLATED_HOST = i18n.translate('xpack.securitySolution.caseView.isolatedHost', {
defaultMessage: 'submitted isolate request on host',
});

export const RELEASED_HOST = i18n.translate('xpack.securitySolution.caseView.releasedHost', {
defaultMessage: 'submitted release request on host',
});

export const OTHER_ENDPOINTS = (endpoints: number): string =>
i18n.translate('xpack.securitySolution.caseView.otherEndpoints', {
values: { endpoints },
defaultMessage: ` and {endpoints} {endpoints, plural, =1 {other} other {others}}`,
});
Loading

0 comments on commit c6f5418

Please sign in to comment.