mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] Insight license/error boundary (#158085)
## Summary This pr surfaces any errors encountered when rendering user/rule author supplied markdown, by showing an error message in a callout that was previously only visible when editing markdown. This will also help surface any errors due to version changes or mismatches with Kibana and rule versions. This feature has also been moved behind a license of platinum+, with a blue callout linking to the licensing page displayed when users have a basic license and a markdown snippet is rendered with a plugin defined as premium in the string. Note that the error messages are displayed below just by throwing on https://github.com/elastic/kibana/pull/158085/files#diff-fb079e6773b60c806c43a201776d683c46115f60d719ce7ca1c74702cc585b9aR54 to show the error callout. The markdown renders correctly in this case, but an actual error would render as a plain string. License in alert detail: <img width="527" alt="image" src="b782cb74
-5f32-434a-aaf4-15a11b07ea25"> Basic license and invalid markdown: <img width="532" alt="image" src="198e1725
-e19e-462f-8ba0-c412ecbd6da5"> Error with platinum+: <img width="548" alt="image" src="3167bab1
-63b5-4cc8-adaf-e8c6277e0fad"> No error, platinum license (existing behavior): <img width="520" alt="image" src="3d1dd15c
-543c-4d49-bd58-16fe89c4b657"> ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
This commit is contained in:
parent
ed0d341757
commit
405d437f0b
6 changed files with 301 additions and 63 deletions
|
@ -21,6 +21,8 @@ export const { uiPlugins, parsingPlugins, processingPlugins } = {
|
|||
processingPlugins: getDefaultEuiMarkdownProcessingPlugins(),
|
||||
};
|
||||
|
||||
export const platinumOnlyPluginTokens = [insightMarkdownPlugin.insightPrefix];
|
||||
|
||||
uiPlugins.push(timelineMarkdownPlugin.plugin);
|
||||
uiPlugins.push(osqueryMarkdownPlugin.plugin);
|
||||
uiPlugins.push(insightMarkdownPlugin.plugin);
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
DEFAULT_TO,
|
||||
} from '../../../../../../common/constants';
|
||||
import { KibanaServices } from '../../../../lib/kibana';
|
||||
import { licenseService } from '../../../../hooks/use_license';
|
||||
import type { DefaultTimeRangeSetting } from '../../../../utils/default_date_settings';
|
||||
import { renderer as Renderer } from '.';
|
||||
import type { InvestigateInTimelineButtonProps } from '../../../event_details/table/investigate_in_timeline_button';
|
||||
|
@ -58,33 +59,74 @@ const mockTimeRange = (
|
|||
}));
|
||||
};
|
||||
|
||||
jest.mock('../../../../hooks/use_license', () => {
|
||||
const licenseServiceInstance = {
|
||||
isPlatinumPlus: jest.fn(),
|
||||
isEnterprise: jest.fn(() => true),
|
||||
};
|
||||
return {
|
||||
licenseService: licenseServiceInstance,
|
||||
useLicense: () => {
|
||||
return licenseServiceInstance;
|
||||
},
|
||||
};
|
||||
});
|
||||
const licenseServiceMock = licenseService as jest.Mocked<typeof licenseService>;
|
||||
|
||||
describe('insight component renderer', () => {
|
||||
beforeEach(() => {
|
||||
mockTimeRange(null);
|
||||
describe('when license is at least platinum plus', () => {
|
||||
beforeAll(() => {
|
||||
licenseServiceMock.isPlatinumPlus.mockReturnValue(true);
|
||||
mockTimeRange(null);
|
||||
});
|
||||
it('renders correctly with valid date strings with no timestamp from results', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<Renderer
|
||||
label={'test label'}
|
||||
description={'test description'}
|
||||
providers={
|
||||
'[[{"field":"event.id","value":"{{kibana.alert.original_event.id}}","queryType":"phrase", "excluded": "false"}],[{"field":"event.category","value":"network","queryType":"phrase", "excluded": "false"}},{"field":"process.pid","value":"process.pid","queryType":"phrase", "excluded": "false", "valueType":"number"}}]]'
|
||||
}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
const timelineButton = screen.getByTestId('insight-investigate-in-timeline-button');
|
||||
const relativeTo = timelineButton.getAttribute('data-timerange-to') || '';
|
||||
const relativeFrom = timelineButton.getAttribute('data-timerange-from') || '';
|
||||
expect(timelineButton).toHaveAttribute('data-timerange-kind', 'relative');
|
||||
try {
|
||||
const toDate = new Date(relativeTo);
|
||||
const fromDate = new Date(relativeFrom);
|
||||
expect(moment(toDate).isValid()).toBe(true);
|
||||
expect(moment(fromDate).isValid()).toBe(true);
|
||||
} catch {
|
||||
expect(false).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
it('renders correctly with valid date strings with no timestamp from results', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<Renderer
|
||||
label={'test label'}
|
||||
description={'test description'}
|
||||
providers={
|
||||
'[[{"field":"event.id","value":"{{kibana.alert.original_event.id}}","queryType":"phrase", "excluded": "false"}],[{"field":"event.category","value":"network","queryType":"phrase", "excluded": "false"}},{"field":"process.pid","value":"process.pid","queryType":"phrase", "excluded": "false", "valueType":"number"}}]]'
|
||||
}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
const timelineButton = screen.getByTestId('insight-investigate-in-timeline-button');
|
||||
const relativeTo = timelineButton.getAttribute('data-timerange-to') || '';
|
||||
const relativeFrom = timelineButton.getAttribute('data-timerange-from') || '';
|
||||
expect(timelineButton).toHaveAttribute('data-timerange-kind', 'relative');
|
||||
try {
|
||||
const toDate = new Date(relativeTo);
|
||||
const fromDate = new Date(relativeFrom);
|
||||
expect(moment(toDate).isValid()).toBe(true);
|
||||
expect(moment(fromDate).isValid()).toBe(true);
|
||||
} catch {
|
||||
expect(false).toBe(true);
|
||||
}
|
||||
|
||||
describe('when license is not at least platinum plus', () => {
|
||||
beforeAll(() => {
|
||||
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
|
||||
mockTimeRange(null);
|
||||
});
|
||||
it('renders a disabled eui button with label', () => {
|
||||
const { getByText } = render(
|
||||
<TestProviders>
|
||||
<Renderer
|
||||
label={'test label'}
|
||||
description={'test description'}
|
||||
providers={
|
||||
'[[{"field":"event.id","value":"{{kibana.alert.original_event.id}}","queryType":"phrase", "excluded": "false"}],[{"field":"event.category","value":"network","queryType":"phrase", "excluded": "false"}},{"field":"process.pid","value":"process.pid","queryType":"phrase", "excluded": "false", "valueType":"number"}}]]'
|
||||
}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
expect(getByText((_, element) => element?.tagName === 'BUTTON')).toHaveAttribute(
|
||||
'disabled',
|
||||
''
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
EuiLoadingSpinner,
|
||||
EuiIcon,
|
||||
EuiSpacer,
|
||||
EuiCallOut,
|
||||
EuiBetaBadge,
|
||||
EuiCodeBlock,
|
||||
EuiModalHeader,
|
||||
|
@ -54,6 +55,7 @@ import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/consta
|
|||
import { useSourcererDataView } from '../../../../containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../../store/sourcerer/model';
|
||||
import { filtersToInsightProviders } from './provider';
|
||||
import { useLicense } from '../../../../hooks/use_license';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface InsightComponentProps {
|
||||
|
@ -64,7 +66,7 @@ interface InsightComponentProps {
|
|||
relativeTo?: string;
|
||||
}
|
||||
|
||||
const insightPrefix = '!{investigate';
|
||||
export const insightPrefix = '!{investigate';
|
||||
|
||||
export const parser: Plugin = function () {
|
||||
const Parser = this.Parser;
|
||||
|
@ -132,8 +134,7 @@ export const parser: Plugin = function () {
|
|||
|
||||
const resultFormat = '0,0.[000]a';
|
||||
|
||||
// receives the configuration from the parser and renders
|
||||
const InsightComponent = ({
|
||||
const LicensedInsightComponent = ({
|
||||
label,
|
||||
description,
|
||||
providers,
|
||||
|
@ -208,9 +209,8 @@ const InsightComponent = ({
|
|||
};
|
||||
}
|
||||
}, [oldestTimestamp, relativeTimerange]);
|
||||
|
||||
if (isQueryLoading) {
|
||||
return <EuiLoadingSpinner size="l" />;
|
||||
return <EuiLoadingSpinner />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
|
@ -232,6 +232,43 @@ const InsightComponent = ({
|
|||
}
|
||||
};
|
||||
|
||||
// receives the configuration from the parser and renders
|
||||
const InsightComponent = ({
|
||||
label,
|
||||
description,
|
||||
providers,
|
||||
relativeFrom,
|
||||
relativeTo,
|
||||
}: InsightComponentProps) => {
|
||||
const isPlatinum = useLicense().isPlatinumPlus();
|
||||
|
||||
if (isPlatinum === false) {
|
||||
return (
|
||||
<>
|
||||
<EuiButton
|
||||
isDisabled={true}
|
||||
iconSide={'left'}
|
||||
iconType={'timeline'}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
{`${label}`}
|
||||
</EuiButton>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<LicensedInsightComponent
|
||||
label={label}
|
||||
description={description}
|
||||
providers={providers}
|
||||
relativeFrom={relativeFrom}
|
||||
relativeTo={relativeTo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { InsightComponent as renderer };
|
||||
|
||||
const InsightEditorComponent = ({
|
||||
|
@ -371,6 +408,8 @@ const InsightEditorComponent = ({
|
|||
},
|
||||
];
|
||||
}, [indexPattern]);
|
||||
const isPlatinum = useLicense().isAtLeast('platinum');
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiModalHeader
|
||||
|
@ -399,7 +438,12 @@ const InsightEditorComponent = ({
|
|||
</EuiFlexGroup>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
{isPlatinum === false && (
|
||||
<EuiCallOut
|
||||
title="To add suggested queries to an investigation guide, please upgrade to platinum"
|
||||
iconType="timeline"
|
||||
/>
|
||||
)}
|
||||
<EuiModalBody>
|
||||
<FormProvider {...formMethods}>
|
||||
<EuiForm fullWidth>
|
||||
|
|
|
@ -6,54 +6,123 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { removeExternalLinkText } from '@kbn/securitysolution-io-ts-utils';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { MarkdownRenderer } from './renderer';
|
||||
|
||||
jest.mock('../../utils/default_date_settings', () => {
|
||||
const original = jest.requireActual('../../utils/default_date_settings');
|
||||
return {
|
||||
...original,
|
||||
getTimeRangeSettings: () => ({ to: '', from: '' }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../utils/normalize_time_range', () => {
|
||||
const original = jest.requireActual('../../utils/normalize_time_range');
|
||||
return {
|
||||
...original,
|
||||
normalizeTimeRange: () => ({ to: '', from: '' }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../lib/kibana/kibana_react', () => {
|
||||
const original = jest.requireActual('../../lib/kibana/kibana_react');
|
||||
return {
|
||||
useKibana: () => ({
|
||||
...original,
|
||||
services: {
|
||||
...original.services,
|
||||
chrome: undefined,
|
||||
application: {
|
||||
navigateToApp: jest.fn(),
|
||||
getUrlForApp: (appId: string, options?: { path?: string; deepLinkId?: boolean }) =>
|
||||
`${appId}/${options?.deepLinkId ?? ''}${options?.path ?? ''}`,
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
data: {
|
||||
dataViews: jest.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../hooks/use_app_toasts', () => ({
|
||||
useAppToasts: jest.fn().mockReturnValue({
|
||||
addError: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Markdown', () => {
|
||||
describe('markdown links', () => {
|
||||
const markdownWithLink = 'A link to an external site [External Site](https://google.com)';
|
||||
|
||||
test('it renders the expected link text', () => {
|
||||
const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
const { getAllByText } = render(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
|
||||
expect(
|
||||
removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text())
|
||||
).toContain('External Site');
|
||||
getAllByText(removeExternalLinkText('External Site'), { exact: false })[0]
|
||||
).toHaveTextContent('External Site');
|
||||
});
|
||||
|
||||
test('it renders the expected href', () => {
|
||||
const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="markdown-link"]').first().find('a').getDOMNode()
|
||||
).toHaveProperty('href', 'https://google.com/');
|
||||
const { getByText } = render(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
expect(getByText((_, element) => element?.tagName === 'A')).toHaveAttribute(
|
||||
'href',
|
||||
'https://google.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('it does NOT render the href if links are disabled', () => {
|
||||
const wrapper = mount(
|
||||
test('it renders the content as a text node if links are disabled', () => {
|
||||
const { getByText } = render(
|
||||
<MarkdownRenderer disableLinks={true}>{markdownWithLink}</MarkdownRenderer>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()
|
||||
).not.toHaveProperty('href');
|
||||
expect(getByText((_, element) => element?.tagName === 'P')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it opens links in a new tab via target="_blank"', () => {
|
||||
const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="markdown-link"]').first().find('a').getDOMNode()
|
||||
).toHaveProperty('target', '_blank');
|
||||
const { getByText } = render(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
expect(getByText((_, element) => element?.tagName === 'A')).toHaveAttribute(
|
||||
'target',
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
|
||||
test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => {
|
||||
const wrapper = mount(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
const { getByText } = render(<MarkdownRenderer>{markdownWithLink}</MarkdownRenderer>);
|
||||
expect(getByText((_, element) => element?.tagName === 'A')).toHaveAttribute(
|
||||
'rel',
|
||||
'nofollow noopener noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="markdown-link"]').first().find('a').getDOMNode()
|
||||
).toHaveProperty('rel', 'nofollow noopener noreferrer');
|
||||
test('displays an error callout when invalid markdown is detected', () => {
|
||||
const { getByText } = render(
|
||||
<MarkdownRenderer>{`!{investigate{"label": "Test action", "description": "Click to investigate", "providers": [[{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}],{"field":"event.action", "value": "", "queryType": "exists", "excluded": "false"},{"field": "process.pid", "value": "{{process.pid}}", "queryType": "phrase", "excluded":"false"}]]}}`}</MarkdownRenderer>
|
||||
);
|
||||
|
||||
const errorCallout = getByText(/Invalid markdown detected/i);
|
||||
expect(errorCallout).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays an upgrade message with a premium markdown plugin', () => {
|
||||
const { queryByText, getByText } = render(
|
||||
<TestProviders>
|
||||
<MarkdownRenderer>{`!{investigate{"label": "", "providers": [[{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}]]}}`}</MarkdownRenderer>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const errorCallout = queryByText(/Invalid markdown detected/i);
|
||||
expect(errorCallout).toEqual(null);
|
||||
|
||||
const upgradeMessage = getByText(/upgrade your subscription/i);
|
||||
expect(upgradeMessage).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
import React, { memo, useMemo } from 'react';
|
||||
import { cloneDeep } from 'lodash/fp';
|
||||
import type { EuiLinkAnchorProps } from '@elastic/eui';
|
||||
import { EuiMarkdownFormat } from '@elastic/eui';
|
||||
import { EuiMarkdownFormat, EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui';
|
||||
import unified from 'unified';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { parsingPlugins, processingPlugins } from './plugins';
|
||||
import { parsingPlugins, processingPlugins, platinumOnlyPluginTokens } from './plugins';
|
||||
import { MarkdownLink } from './markdown_link';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { useLicense } from '../../hooks/use_license';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
|
@ -28,14 +33,74 @@ const MarkdownRendererComponent: React.FC<Props> = ({ children, disableLinks })
|
|||
const processingPluginList = cloneDeep(processingPlugins);
|
||||
// This line of code is TS-compatible and it will break if [1][1] change in the future.
|
||||
processingPluginList[1][1].components.a = MarkdownLinkProcessingComponent;
|
||||
const isPlatinum = useLicense().isAtLeast('platinum');
|
||||
const { application } = useKibana().services;
|
||||
const platinumPluginDetected = useMemo(() => {
|
||||
if (isPlatinum === false) {
|
||||
const markdownString = String(children);
|
||||
return platinumOnlyPluginTokens.some((token) => {
|
||||
const regex = new RegExp(token);
|
||||
return regex.test(markdownString);
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, [children, isPlatinum]);
|
||||
const processor = useMemo(
|
||||
() => unified().use(parsingPlugins).use(processingPluginList),
|
||||
[processingPluginList]
|
||||
);
|
||||
const markdownParseResult = useMemo(() => {
|
||||
try {
|
||||
processor.processSync(children);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return String(err.message);
|
||||
}
|
||||
}, [children, processor]);
|
||||
|
||||
return (
|
||||
<EuiMarkdownFormat
|
||||
parsingPluginList={parsingPlugins}
|
||||
processingPluginList={processingPluginList}
|
||||
>
|
||||
{children}
|
||||
</EuiMarkdownFormat>
|
||||
<>
|
||||
{platinumPluginDetected && (
|
||||
<>
|
||||
<EuiCallOut title={i18n.PLATINUM_WARNING} color="primary" iconType="lock">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.premiumPluginLinkPrefix"
|
||||
defaultMessage="To use these interactive markdown features, you must {link}."
|
||||
values={{
|
||||
link: (
|
||||
<EuiLink
|
||||
href={application.getUrlForApp('management', {
|
||||
path: 'stack/license_management/home',
|
||||
})}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.markdown.premiumPluginLinkSuffix"
|
||||
defaultMessage="start a trial or upgrade your subscription"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
{markdownParseResult !== null && (
|
||||
<>
|
||||
<EuiCallOut title={i18n.INVALID_MARKDOWN} color="danger" iconType="error">
|
||||
{markdownParseResult}
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
<EuiMarkdownFormat
|
||||
parsingPluginList={parsingPlugins}
|
||||
processingPluginList={processingPluginList}
|
||||
>
|
||||
{children}
|
||||
</EuiMarkdownFormat>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 PLATINUM_WARNING = i18n.translate('xpack.securitySolution.markdown.platinumWarning', {
|
||||
defaultMessage: 'The following markdown may make use of subscription features',
|
||||
});
|
||||
|
||||
export const INVALID_MARKDOWN = i18n.translate('xpack.securitySolution.markdown.invalid', {
|
||||
defaultMessage: 'Invalid markdown detected',
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue