[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:
Kevin Qualters 2023-06-08 00:15:49 -04:00 committed by GitHub
parent ed0d341757
commit 405d437f0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 301 additions and 63 deletions

View file

@ -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);

View file

@ -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',
''
);
});
});
});

View file

@ -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>

View file

@ -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();
});
});
});

View file

@ -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>
</>
);
};

View file

@ -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',
});