[TIP] Investigate in timeline (#140496)

* [TIP] Investigate in timeline

- 2 InvestigateInTimeline components (one for Button display the other for ButtonIcon) and 1 useInvestigateInTimeline hook
- add new investigate in timeline hook in Security Solution plugin and pass via context to TI plugin
- replace UrlOriginal by UrlFull in the threat.indicator.name mapping
- bump kbn-optimizer limit for threatIntelligence
- add EuiTooltip for all EuiButtonIcon
- add missing translations
- replace css with EuiFlexGroup where possible
This commit is contained in:
Philippe Oberti 2022-09-19 13:28:03 -05:00 committed by GitHub
parent 6add682702
commit 3099159d02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1330 additions and 230 deletions

View file

@ -110,7 +110,7 @@ pageLoadAssetSize:
synthetics: 40958
telemetry: 51957
telemetryManagementSection: 38586
threatIntelligence: 29195
threatIntelligence: 44299
timelines: 327300
transform: 41007
triggersActionsUi: 119000

View file

@ -11,6 +11,9 @@ import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import type { SecuritySolutionPluginContext } from '@kbn/threat-intelligence-plugin/public';
import { THREAT_INTELLIGENCE_BASE_PATH } from '@kbn/threat-intelligence-plugin/public';
import type { SourcererDataView } from '@kbn/threat-intelligence-plugin/public/types';
import type { Store } from 'redux';
import { useInvestigateInTimeline } from './use_investigate_in_timeline';
import { getStore } from '../common/store';
import { useKibana } from '../common/lib/kibana';
import { FiltersGlobal } from '../common/components/filters_global';
import { SpyRoute } from '../common/utils/route/spy_routes';
@ -32,11 +35,15 @@ const ThreatIntelligence = memo(() => {
return <Redirect to="/" />;
}
const securitySolutionStore = getStore() as Store;
const securitySolutionContext: SecuritySolutionPluginContext = {
getFiltersGlobalComponent: () => FiltersGlobal,
getPageWrapper: () => SecuritySolutionPageWrapper,
licenseService,
sourcererDataView: sourcererDataView as unknown as SourcererDataView,
getSecuritySolutionStore: securitySolutionStore,
getUseInvestigateInTimeline: useInvestigateInTimeline,
};
return (

View file

@ -0,0 +1,15 @@
/*
* 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 ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate(
'xpack.securitySolution.threatIntelligence.investigateInTimelineTitle',
{
defaultMessage: 'Investigate in timeline',
}
);

View file

@ -0,0 +1,130 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { timelineDefaults } from '../timelines/store/timeline/defaults';
import { APP_UI_ID } from '../../common/constants';
import type { DataProvider } from '../../common/types';
import { TimelineId, TimelineType } from '../../common/types';
import { useDeepEqualSelector } from '../common/hooks/use_selector';
import { useKibana } from '../common/lib/kibana';
import { useStartTransaction } from '../common/lib/apm/use_start_transaction';
import { timelineActions, timelineSelectors } from '../timelines/store/timeline';
import { useCreateTimeline } from '../timelines/components/timeline/properties/use_create_timeline';
import type { CreateTimelineProps } from '../detections/components/alerts_table/types';
import { dispatchUpdateTimeline } from '../timelines/components/open_timeline/helpers';
interface UseInvestigateInTimelineActionProps {
/**
* Created when the user clicks on the Investigate in Timeline button.
* DataProvider contain the field(s) and value(s) displayed in the timeline.
*/
dataProviders: DataProvider[];
/**
* Start date used in the createTimeline method.
*/
from: string;
/**
* End date used in the createTimeline method.
*/
to: string;
}
/**
* Hook passed down to the Threat Intelligence plugin, via context.
* This code is closely duplicated from here: https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx,
* the main changes being:
* - no exceptions are handled at the moment
* - we use dataProviders, from and to directly instead of consuming ecsData
*/
export const useInvestigateInTimeline = ({
dataProviders,
from,
to,
}: UseInvestigateInTimelineActionProps) => {
const {
data: { query },
} = useKibana().services;
const dispatch = useDispatch();
const { startTransaction } = useStartTransaction();
const filterManagerBackup = useMemo(() => query.filterManager, [query.filterManager]);
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { filterManager: activeFilterManager } = useDeepEqualSelector((state) =>
getManageTimeline(state, TimelineId.active ?? '')
);
const filterManager = useMemo(
() => activeFilterManager ?? filterManagerBackup,
[activeFilterManager, filterManagerBackup]
);
const updateTimelineIsLoading = useCallback(
(payload) => dispatch(timelineActions.updateIsLoading(payload)),
[dispatch]
);
const clearActiveTimeline = useCreateTimeline({
timelineId: TimelineId.active,
timelineType: TimelineType.default,
});
const createTimeline = useCallback(
({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => {
clearActiveTimeline();
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
dispatchUpdateTimeline(dispatch)({
duplicate: true,
from: fromTimeline,
id: TimelineId.active,
notes: [],
timeline: {
...timeline,
filterManager,
indexNames: timeline.indexNames ?? [],
show: true,
},
to: toTimeline,
ruleNote,
})();
},
[dispatch, filterManager, updateTimelineIsLoading, clearActiveTimeline]
);
const investigateInTimelineClick = useCallback(async () => {
startTransaction({ name: `${APP_UI_ID} threat indicator investigateInTimeline` });
await createTimeline({
from,
notes: null,
timeline: {
...timelineDefaults,
dataProviders,
id: TimelineId.active,
indexNames: [],
dateRange: {
start: from,
end: to,
},
eventType: 'all',
filters: [],
kqlQuery: {
filterQuery: {
kuery: {
kind: 'kuery',
expression: '',
},
serializedQuery: '',
},
},
},
to,
ruleNote: '',
});
}, [startTransaction, createTimeline, dataProviders, from, to]);
return investigateInTimelineClick;
};

View file

@ -38,8 +38,10 @@ export enum RawIndicatorFieldId {
FileImphash = 'threat.indicator.file.imphash',
FilePehash = 'threat.indicator.file.pehash',
FileVhash = 'threat.indicator.file.vhash',
FileTelfhash = 'threat.indicator.file.elf.telfhash',
X509Serial = 'threat.indicator.x509.serial_number',
WindowsRegistryKey = 'threat.indicator.registry.key',
WindowsRegistryPath = 'threat.indicator.registry.path',
AutonomousSystemNumber = 'threat.indicator.as.number',
MacAddress = 'threat.indicator.mac',
TimeStamp = '@timestamp',
@ -49,6 +51,22 @@ export enum RawIndicatorFieldId {
NameOrigin = 'threat.indicator.name_origin',
}
/**
* Threat indicator field map to Enriched Event.
* (reverse of https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/common/cti/constants.ts#L35)
*/
export const IndicatorFieldEventEnrichmentMap: { [id: string]: string[] } = {
[RawIndicatorFieldId.FileMd5]: ['file.hash.md5'],
[RawIndicatorFieldId.FileSha1]: ['file.hash.sha1'],
[RawIndicatorFieldId.FileSha256]: ['file.hash.sha256'],
[RawIndicatorFieldId.FileImphash]: ['file.pe.imphash'],
[RawIndicatorFieldId.FileTelfhash]: ['file.elf.telfhash'],
[RawIndicatorFieldId.FileSSDeep]: ['file.hash.ssdeep'],
[RawIndicatorFieldId.Ip]: ['source.ip', 'destination.ip'],
[RawIndicatorFieldId.UrlFull]: ['url.full'],
[RawIndicatorFieldId.WindowsRegistryPath]: ['registry.path'],
};
/**
* Threat Intelligence Indicator interface.
*/
@ -93,7 +111,7 @@ export const generateMockUrlIndicator = (): Indicator => {
indicator.fields['threat.indicator.url.full'] = ['https://0.0.0.0/test'];
indicator.fields['threat.indicator.url.original'] = ['https://0.0.0.0/test'];
indicator.fields['threat.indicator.name'] = ['https://0.0.0.0/test'];
indicator.fields['threat.indicator.name_origin'] = ['threat.indicator.url.original'];
indicator.fields['threat.indicator.name_origin'] = ['threat.indicator.url.full'];
return indicator;
};

View file

@ -19,6 +19,8 @@ import {
UNTITLED_TIMELINE_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_TIMELINE_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM,
INDICATORS_TABLE_INVESTIGATE_IN_TIMELINE_BUTTON_ICON,
INDICATOR_FLYOUT_INVESTIGATE_IN_TIMELINE_BUTTON,
} from '../screens/indicators';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { login } from '../tasks/login';
@ -88,5 +90,19 @@ describe('Indicators', () => {
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should investigate in timeline when clicking in an indicator table action row', () => {
cy.get(INDICATORS_TABLE_INVESTIGATE_IN_TIMELINE_BUTTON_ICON).should('exist').first().click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should investigate in timeline when clicking in an indicator flyout', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(INDICATOR_FLYOUT_INVESTIGATE_IN_TIMELINE_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
});
});

View file

@ -101,3 +101,9 @@ export const UNTITLED_TIMELINE_BUTTON = '[data-test-subj="flyoutOverlay"]';
export const TIMELINE_DRAGGABLE_ITEM = '[data-test-subj="providerContainer"]';
export const KQL_FILTER = '[id="popoverFor_filter0"]';
export const INDICATORS_TABLE_INVESTIGATE_IN_TIMELINE_BUTTON_ICON =
'[data-test-subj="tiIndicatorTableInvestigateInTimelineButtonIcon"]';
export const INDICATOR_FLYOUT_INVESTIGATE_IN_TIMELINE_BUTTON =
'[data-test-subj="tiIndicatorFlyoutInvestigateInTimelineButton"]';

View file

@ -28,4 +28,12 @@ export const getSecuritySolutionContextMock = (): SecuritySolutionPluginContext
indexPattern: { fields: [], title: '' },
loading: false,
},
getSecuritySolutionStore: {
// @ts-ignore
dispatch: () => jest.fn(),
},
getUseInvestigateInTimeline:
({ dataProviders, from, to }) =>
() =>
new Promise((resolve) => window.alert('investigate in timeline')),
});

View file

@ -6,7 +6,8 @@
*/
import React, { useState, VFC } from 'react';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ComponentType } from '../../../../../common/types/component_type';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
@ -17,6 +18,10 @@ export const TIMELINE_BUTTON_TEST_ID = 'tiBarchartTimelineButton';
export const FILTER_IN_BUTTON_TEST_ID = 'tiBarchartFilterInButton';
export const FILTER_OUT_BUTTON_TEST_ID = 'tiBarchartFilterOutButton';
const BUTTON_LABEL = i18n.translate('xpack.threatIntelligence.indicator.barChart.popover', {
defaultMessage: 'More actions',
});
export interface IndicatorBarchartLegendActionProps {
/**
* Indicator
@ -59,12 +64,15 @@ export const IndicatorBarchartLegendAction: VFC<IndicatorBarchartLegendActionPro
<EuiPopover
data-test-subj={POPOVER_BUTTON_TEST_ID}
button={
<EuiButtonIcon
iconType="boxesHorizontal"
iconSize="s"
size="xs"
onClick={() => setPopover(!isPopoverOpen)}
/>
<EuiToolTip content={BUTTON_LABEL}>
<EuiButtonIcon
aria-label={BUTTON_LABEL}
iconType="boxesHorizontal"
iconSize="s"
size="xs"
onClick={() => setPopover(!isPopoverOpen)}
/>
</EuiToolTip>
}
isOpen={isPopoverOpen}
closePopover={() => setPopover(false)}

View file

@ -7,12 +7,12 @@
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import React, { VFC } from 'react';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { EuiFlexGroup } from '@elastic/eui';
import { Indicator } from '../../../../../common/types/indicator';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { getIndicatorFieldAndValue } from '../../lib/field_value';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../lib/field_value';
export const TIMELINE_BUTTON_TEST_ID = 'TimelineButton';
export const FILTER_IN_BUTTON_TEST_ID = 'FilterInButton';
@ -44,8 +44,7 @@ export const IndicatorValueActions: VFC<IndicatorValueActions> = ({
...props
}) => {
const { key, value } = getIndicatorFieldAndValue(indicator, field);
if (!key || value === EMPTY_VALUE || !key) {
if (!fieldAndValueValid(key, value)) {
return null;
}
@ -54,7 +53,7 @@ export const IndicatorValueActions: VFC<IndicatorValueActions> = ({
const timelineTestId = `${props['data-test-subj']}${TIMELINE_BUTTON_TEST_ID}`;
return (
<>
<EuiFlexGroup justifyContent="center" alignItems="center">
<FilterIn as={Component} data={indicator} field={field} data-test-subj={filterInTestId} />
<FilterOut as={Component} data={indicator} field={field} data-test-subj={filterOutTestId} />
<AddToTimeline
@ -63,6 +62,6 @@ export const IndicatorValueActions: VFC<IndicatorValueActions> = ({
field={field}
data-test-subj={timelineTestId}
/>
</>
</EuiFlexGroup>
);
};

View file

@ -7,8 +7,11 @@
import React, { useMemo, useState, VFC } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSpacer,
EuiTab,
@ -18,6 +21,7 @@ import {
useGeneratedHtmlId,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { InvestigateInTimelineButton } from '../../../timeline/components/investigate_in_timeline_button';
import { DateFormatter } from '../../../../components/date_formatter/date_formatter';
import { Indicator, RawIndicatorFieldId } from '../../../../../common/types/indicator';
import { IndicatorsFlyoutJson } from './tabs/indicators_flyout_json/indicators_flyout_json';
@ -28,6 +32,7 @@ import { IndicatorsFlyoutOverview } from './tabs/indicators_flyout_overview';
export const TITLE_TEST_ID = 'tiIndicatorFlyoutTitle';
export const SUBTITLE_TEST_ID = 'tiIndicatorFlyoutSubtitle';
export const TABS_TEST_ID = 'tiIndicatorFlyoutTabs';
export const INVESTIGATE_IN_TIMELINE_BUTTON_ID = 'tiIndicatorFlyoutInvestigateInTimelineButton';
const enum TAB_IDS {
overview,
@ -142,6 +147,16 @@ export const IndicatorsFlyout: VFC<IndicatorsFlyoutProps> = ({ indicator, closeF
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>{selectedTabContent}</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<InvestigateInTimelineButton
data={indicator}
data-test-subj={INVESTIGATE_IN_TIMELINE_BUTTON_ID}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -25,7 +25,7 @@ const VisibleOnHover = euiStyled.div`
& .actionsWrapper {
visibility: hidden;
display: inline-block;
margin-inline-start: ${theme.eui.euiSizeXS};
margin-inline-start: ${theme.eui.euiSizeS};
}
&:hover .actionsWrapper {

View file

@ -6,10 +6,14 @@
*/
import React, { useContext, VFC } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { InvestigateInTimelineButtonIcon } from '../../../timeline/components/investigate_in_timeline_button_icon';
import { Indicator } from '../../../../../common/types/indicator';
import { OpenIndicatorFlyoutButton } from '../open_indicator_flyout_button/open_indicator_flyout_button';
import { IndicatorsTableContext } from './context';
const INVESTIGATE_TEST_ID = 'tiIndicatorTableInvestigateInTimelineButtonIcon';
export const ActionsRowCell: VFC<{ indicator: Indicator }> = ({ indicator }) => {
const indicatorTableContext = useContext(IndicatorsTableContext);
@ -20,10 +24,13 @@ export const ActionsRowCell: VFC<{ indicator: Indicator }> = ({ indicator }) =>
const { setExpanded, expanded } = indicatorTableContext;
return (
<OpenIndicatorFlyoutButton
indicator={indicator}
onOpen={setExpanded}
isOpen={Boolean(expanded && expanded._id === indicator._id)}
/>
<EuiFlexGroup justifyContent="center">
<OpenIndicatorFlyoutButton
indicator={indicator}
onOpen={setExpanded}
isOpen={Boolean(expanded && expanded._id === indicator._id)}
/>
<InvestigateInTimelineButtonIcon data={indicator} data-test-subj={INVESTIGATE_TEST_ID} />
</EuiFlexGroup>
);
};

View file

@ -8,11 +8,10 @@
import React, { VFC } from 'react';
import { EuiDataGridColumnCellActionProps } from '@elastic/eui/src/components/datagrid/data_grid_types';
import { ComponentType } from '../../../../../common/types/component_type';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { Pagination } from '../../hooks/use_indicators';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { getIndicatorFieldAndValue } from '../../lib/field_value';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../lib/field_value';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
@ -47,8 +46,7 @@ export const CellActions: VFC<CellActionsProps> = ({
}) => {
const indicator = indicators[rowIndex % pagination.pageSize];
const { key, value } = getIndicatorFieldAndValue(indicator, columnId);
if (!value || value === EMPTY_VALUE || !key) {
if (!fieldAndValueValid(key, value)) {
return <></>;
}

View file

@ -12,6 +12,13 @@ import { Indicator } from '../../../../../common/types/indicator';
export const BUTTON_TEST_ID = 'tiToggleIndicatorFlyoutButton';
const BUTTON_LABEL: string = i18n.translate(
'xpack.threatIntelligence.indicator.table.viewDetailsButton',
{
defaultMessage: 'View details',
}
);
export interface OpenIndicatorFlyoutButtonProps {
/**
* {@link Indicator} passed to the flyout component.
@ -35,22 +42,15 @@ export const OpenIndicatorFlyoutButton: VFC<OpenIndicatorFlyoutButtonProps> = ({
onOpen,
isOpen,
}) => {
const buttonLabel: string = i18n.translate(
'xpack.threatIntelligence.indicator.table.viewDetailsButton',
{
defaultMessage: 'View details',
}
);
return (
<EuiToolTip content={buttonLabel} delay="long">
<EuiToolTip content={BUTTON_LABEL}>
<EuiButtonIcon
data-test-subj={BUTTON_TEST_ID}
color={isOpen ? 'text' : 'primary'}
iconType={isOpen ? 'minimize' : 'expand'}
isSelected={isOpen}
iconSize="s"
aria-label={buttonLabel}
aria-label={BUTTON_LABEL}
onClick={() => onOpen(indicator)}
/>
</EuiToolTip>

View file

@ -33,7 +33,7 @@ describe('display name generation', () => {
if (doc['threat.indicator.file.pehash'].value!=null) { return emit(doc['threat.indicator.file.pehash'].value) }
if (doc['threat.indicator.file.vhash'].value!=null) { return emit(doc['threat.indicator.file.vhash'].value) } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='url') { if (doc['threat.indicator.url.original'].value!=null) { return emit(doc['threat.indicator.url.original'].value) } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='url') { if (doc['threat.indicator.url.full'].value!=null) { return emit(doc['threat.indicator.url.full'].value) } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='domain') { if (doc['threat.indicator.url.domain'].value!=null) { return emit(doc['threat.indicator.url.domain'].value) } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='domain-name') { if (doc['threat.indicator.url.domain'].value!=null) { return emit(doc['threat.indicator.url.domain'].value) } }
@ -83,7 +83,7 @@ describe('display name generation', () => {
if (doc['threat.indicator.file.pehash'].value!=null) { return emit('threat.indicator.file.pehash') }
if (doc['threat.indicator.file.vhash'].value!=null) { return emit('threat.indicator.file.vhash') } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='url') { if (doc['threat.indicator.url.original'].value!=null) { return emit('threat.indicator.url.original') } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='url') { if (doc['threat.indicator.url.full'].value!=null) { return emit('threat.indicator.url.full') } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='domain') { if (doc['threat.indicator.url.domain'].value!=null) { return emit('threat.indicator.url.domain') } }
if (doc['threat.indicator.type'].value != null && doc['threat.indicator.type'].value.toLowerCase()=='domain-name') { if (doc['threat.indicator.url.domain'].value!=null) { return emit('threat.indicator.url.domain') } }

View file

@ -42,7 +42,7 @@ const mappingsArray: Mappings = [
RawIndicatorFieldId.FileVhash,
],
],
[['url'], [RawIndicatorFieldId.UrlOriginal]],
[['url'], [RawIndicatorFieldId.UrlFull]],
[['domain', 'domain-name'], [RawIndicatorFieldId.UrlDomain]],
[['x509-certificate', 'x509 serial'], [RawIndicatorFieldId.X509Serial]],
[['email-addr'], [RawIndicatorFieldId.EmailAddress]],

View file

@ -5,39 +5,84 @@
* 2.0.
*/
import { getIndicatorFieldAndValue } from './field_value';
import { fieldAndValueValid, getIndicatorFieldAndValue } from './field_value';
import {
generateMockFileIndicator,
generateMockUrlIndicator,
} from '../../../../common/types/indicator';
import { EMPTY_VALUE } from '../../../../common/constants';
describe('getIndicatorFieldAndValue()', () => {
it('should return field/value pair for an indicator', () => {
const mockData = generateMockUrlIndicator();
const mockKey = 'threat.feed.name';
describe('field_value', () => {
describe('getIndicatorFieldAndValue()', () => {
it('should return field/value pair for an indicator', () => {
const mockData = generateMockUrlIndicator();
const mockKey = 'threat.feed.name';
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(mockKey);
expect(result.value).toEqual((mockData.fields[mockKey] as unknown as string[])[0]);
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(mockKey);
expect(result.value).toEqual((mockData.fields[mockKey] as unknown as string[])[0]);
});
it('should return a null value for an incorrect field', () => {
const mockData = generateMockUrlIndicator();
const mockKey = 'abc';
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(mockKey);
expect(result.value).toBeNull();
});
it('should return field/value pair for an indicator and DisplayName field', () => {
const mockData = generateMockFileIndicator();
const mockKey = 'threat.indicator.name';
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(
(mockData.fields['threat.indicator.name_origin'] as unknown as string[])[0]
);
expect(result.value).toEqual((mockData.fields[mockKey] as unknown as string[])[0]);
});
});
it('should return a null value for an incorrect field', () => {
const mockData = generateMockUrlIndicator();
const mockKey = 'abc';
describe('fieldAndValueValid()', () => {
it('should return false for null value', () => {
const mockField = 'abc';
const mockValue = null;
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(mockKey);
expect(result.value).toBeNull();
});
const result = fieldAndValueValid(mockField, mockValue);
expect(result).toEqual(false);
});
it('should return field/value pair for an indicator and DisplayName field', () => {
const mockData = generateMockFileIndicator();
const mockKey = 'threat.indicator.name';
it('should return false for empty string value', () => {
const mockField = 'abc';
const mockValue = '';
const result = getIndicatorFieldAndValue(mockData, mockKey);
expect(result.key).toEqual(
(mockData.fields['threat.indicator.name_origin'] as unknown as string[])[0]
);
expect(result.value).toEqual((mockData.fields[mockKey] as unknown as string[])[0]);
const result = fieldAndValueValid(mockField, mockValue);
expect(result).toEqual(false);
});
it(`should return false for ${EMPTY_VALUE} value`, () => {
const mockField = 'abc';
const mockValue = EMPTY_VALUE;
const result = fieldAndValueValid(mockField, mockValue);
expect(result).toEqual(false);
});
it('should return false for empty field', () => {
const mockField = '';
const mockValue = 'abc';
const result = fieldAndValueValid(mockField, mockValue);
expect(result).toEqual(false);
});
it('should return true if field and value are correct', () => {
const mockField = 'abc';
const mockValue = 'abc';
const result = fieldAndValueValid(mockField, mockValue);
expect(result).toEqual(true);
});
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { EMPTY_VALUE } from '../../../../common/constants';
import { unwrapValue } from './unwrap_value';
import { Indicator, RawIndicatorFieldId } from '../../../../common/types/indicator';
@ -29,3 +30,12 @@ export const getIndicatorFieldAndValue = (
value,
};
};
/**
* Checks if field and value are correct
* @param field Indicator string field
* @param value Indicator string|null value for the field
* @returns true if correct, false if not
*/
export const fieldAndValueValid = (field: string | null, value: string | null): boolean =>
!!value && value !== EMPTY_VALUE && !!field;

View file

@ -127,6 +127,31 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
>
@ -142,24 +167,7 @@ Object {
/>
</button>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
@ -220,6 +228,29 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Filter In"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Filter In"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
@ -233,22 +264,7 @@ Object {
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</body>,
"container": <div>
<button
aria-label="Filter In"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],

View file

@ -7,13 +7,12 @@
import React, { useCallback, VFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem, EuiToolTip } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { ComponentType } from '../../../../../common/types/component_type';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { FilterIn as FilterInConst, updateFiltersArray } from '../../lib/filter';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
@ -66,16 +65,18 @@ export const FilterIn: VFC<FilterInProps> = ({ data, field, type, as: Component,
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
if (!value || value === EMPTY_VALUE || !key) {
if (!fieldAndValueValid(key, value)) {
return <></>;
}
if (type === ComponentType.EuiDataGrid) {
return (
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterIn} />
</div>
<EuiToolTip content={ICON_TITLE}>
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterIn} />
</div>
</EuiToolTip>
);
}
@ -94,14 +95,16 @@ export const FilterIn: VFC<FilterInProps> = ({ data, field, type, as: Component,
}
return (
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterIn}
{...props}
/>
<EuiToolTip content={ICON_TITLE}>
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterIn}
{...props}
/>
</EuiToolTip>
);
};

View file

@ -127,6 +127,31 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
>
@ -142,24 +167,7 @@ Object {
/>
</button>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
@ -220,6 +228,29 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
</button>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
@ -233,22 +264,7 @@ Object {
data-euiicon-type="minusInCircle"
/>
</button>
</div>
</body>,
"container": <div>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
</button>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],

View file

@ -7,13 +7,12 @@
import React, { useCallback, VFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem, EuiToolTip } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { ComponentType } from '../../../../../common/types/component_type';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { FilterOut as FilterOutConst, updateFiltersArray } from '../../lib/filter';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
@ -66,16 +65,18 @@ export const FilterOut: VFC<FilterOutProps> = ({ data, field, type, as: Componen
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
if (!value || value === EMPTY_VALUE || !key) {
if (!fieldAndValueValid(key, value)) {
return <></>;
}
if (type === ComponentType.EuiDataGrid) {
return (
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterOut} />
</div>
<EuiToolTip content={ICON_TITLE}>
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterOut} />
</div>
</EuiToolTip>
);
}
@ -94,14 +95,16 @@ export const FilterOut: VFC<FilterOutProps> = ({ data, field, type, as: Componen
}
return (
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterOut}
{...props}
/>
<EuiToolTip content={ICON_TITLE}>
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterOut}
{...props}
/>
</EuiToolTip>
);
};

View file

@ -249,8 +249,27 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<div
class="euiFlexItem"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
class="euiFlexItem"
>
<span
data-test-subj="test-add-to-timeline"
@ -258,18 +277,7 @@ Object {
Add To Timeline
</span>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
@ -330,8 +338,27 @@ Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<div
class="euiFlexItem"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<div
css="[object Object]"
class="euiFlexItem"
>
<span
data-test-subj="test-add-to-timeline"
@ -339,18 +366,7 @@ Object {
Add To Timeline
</span>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],

View file

@ -6,13 +6,15 @@
*/
import React, { useRef, VFC } from 'react';
import { DataProvider, QueryOperator } from '@kbn/timelines-plugin/common';
import { DataProvider } from '@kbn/timelines-plugin/common';
import { AddToTimelineButtonProps } from '@kbn/timelines-plugin/public';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui/src/components/button';
import { EuiContextMenuItem } from '@elastic/eui';
import { EuiContextMenuItem, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { generateDataProvider } from '../../lib/data_provider';
import { ComponentType } from '../../../../../common/types/component_type';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { useKibana } from '../../../../hooks/use_kibana';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
@ -61,27 +63,11 @@ export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, type, as,
const { key, value } =
typeof data === 'string' ? { key: field, value: data } : getIndicatorFieldAndValue(data, field);
if (!value || value === EMPTY_VALUE || !key) {
if (!fieldAndValueValid(key, value)) {
return <></>;
}
const operator = ':' as QueryOperator;
const dataProvider: DataProvider[] = [
{
and: [],
enabled: true,
id: `timeline-indicator-${key}-${value}`,
name: value,
excluded: false,
kqlQuery: '',
queryMatch: {
field: key,
value,
operator,
},
},
];
const dataProvider: DataProvider[] = [generateDataProvider(key, value as string)];
const addToTimelineProps: AddToTimelineButtonProps = {
dataProvider,
@ -105,7 +91,10 @@ export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, type, as,
onClick={() => contextMenuRef.current?.click()}
{...props}
>
Add to Timeline
<FormattedMessage
id="xpack.threatIntelligence.addToTimelineContextMenu"
defaultMessage="Add to Timeline"
/>
</EuiContextMenuItem>
</>
);
@ -114,8 +103,12 @@ export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, type, as,
if (as) addToTimelineProps.Component = as;
return (
<div {...props} css={styles.inlineFlex}>
{addToTimelineButton(addToTimelineProps)}
</div>
<EuiToolTip
content={i18n.translate('xpack.threatIntelligence.addToTimelineIconButton', {
defaultMessage: 'Add to Timeline',
})}
>
<EuiFlexItem {...props}>{addToTimelineButton(addToTimelineProps)}</EuiFlexItem>
</EuiToolTip>
);
};

View file

@ -8,16 +8,11 @@
import { CSSObject } from '@emotion/react';
export const useStyles = () => {
const inlineFlex: CSSObject = {
display: 'inline-flex',
};
const displayNone: CSSObject = {
display: 'none',
};
return {
inlineFlex,
displayNone,
};
};

View file

@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<InvestigateInTimelineButton /> should render button when Indicator data is correct 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
class="euiButton euiButton--primary euiButton--fill"
data-test-subj="mockId"
type="button"
>
<span
class="euiButtonContent euiButton__content"
>
<span
class="euiButton__text"
>
Investigate in Timeline
</span>
</span>
</button>
</div>
</body>,
"container": <div>
<button
class="euiButton euiButton--primary euiButton--fill"
data-test-subj="mockId"
type="button"
>
<span
class="euiButtonContent euiButton__content"
>
<span
class="euiButton__text"
>
Investigate in Timeline
</span>
</span>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<InvestigateInTimelineButton /> should render empty component when Indicator data is incorrect 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './investigate_in_timeline_button';

View file

@ -0,0 +1,27 @@
/*
* 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 { Story } from '@storybook/react';
import { StoryProvidersComponent } from '../../../../common/mocks/story_providers';
import { generateMockUrlIndicator } from '../../../../../common/types/indicator';
import { InvestigateInTimelineButton } from './investigate_in_timeline_button';
export default {
component: InvestigateInTimelineButton,
title: 'InvestigateInTimelineButton',
};
const mockIndicator = generateMockUrlIndicator();
export const Default: Story<void> = () => {
return (
<StoryProvidersComponent>
<InvestigateInTimelineButton data={mockIndicator} />
</StoryProvidersComponent>
);
};

View file

@ -0,0 +1,45 @@
/*
* 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 {
generateMockIndicator,
generateMockUrlIndicator,
Indicator,
} from '../../../../../common/types/indicator';
import { TestProvidersComponent } from '../../../../common/mocks/test_providers';
import { InvestigateInTimelineButton } from './investigate_in_timeline_button';
describe('<InvestigateInTimelineButton />', () => {
it('should render button when Indicator data is correct', () => {
const mockData: Indicator = generateMockUrlIndicator();
const mockId = 'mockId';
const component = render(
<TestProvidersComponent>
<InvestigateInTimelineButton data={mockData} data-test-subj={mockId} />
</TestProvidersComponent>
);
expect(component.getByTestId(mockId)).toBeInTheDocument();
expect(component).toMatchSnapshot();
});
it('should render empty component when Indicator data is incorrect', () => {
const mockData: Indicator = generateMockIndicator();
mockData.fields['threat.indicator.first_seen'] = [''];
const component = render(
<TestProvidersComponent>
<InvestigateInTimelineButton data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,50 @@
/*
* 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, { VFC } from 'react';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useInvestigateInTimeline } from '../../hooks/use_investigate_in_timeline';
import { Indicator } from '../../../../../common/types/indicator';
export interface InvestigateInTimelineButtonProps {
/**
* Value passed to the timeline. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator;
/**
* Used for unit and e2e tests.
*/
['data-test-subj']?: string;
}
/**
* Investigate in timeline button, supports being passed a {@link Indicator}.
* This implementation uses the InvestigateInTimelineAction component (x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx)
* retrieved from the SecuritySolutionContext.
*
* @returns add to timeline button or an empty component.
*/
export const InvestigateInTimelineButton: VFC<InvestigateInTimelineButtonProps> = ({
data,
...props
}) => {
const { onClick } = useInvestigateInTimeline({ indicator: data });
if (!onClick) {
return <></>;
}
return (
<EuiButton onClick={onClick} fill {...props}>
<FormattedMessage
defaultMessage="Investigate in Timeline"
id="xpack.threatIntelligence.investigateInTimelineButton"
/>
</EuiButton>
);
};

View file

@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<InvestigateInTimelineButtonIcon /> should render button icon when Indicator data is correct 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Investigate in Timeline"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="mockId"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="timeline"
/>
</button>
</span>
</div>
</body>,
"container": <div>
<span
class="euiToolTipAnchor"
>
<button
aria-label="Investigate in Timeline"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="mockId"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="timeline"
/>
</button>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<InvestigateInTimelineButtonIcon /> should render empty component when calculated value is - 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './investigate_in_timeline_button_icon';

View file

@ -0,0 +1,27 @@
/*
* 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 { Story } from '@storybook/react';
import { StoryProvidersComponent } from '../../../../common/mocks/story_providers';
import { generateMockUrlIndicator } from '../../../../../common/types/indicator';
import { InvestigateInTimelineButtonIcon } from './investigate_in_timeline_button_icon';
export default {
component: InvestigateInTimelineButtonIcon,
title: 'InvestigateInTimelineButtonIcon',
};
const mockIndicator = generateMockUrlIndicator();
export const Default: Story<void> = () => {
return (
<StoryProvidersComponent>
<InvestigateInTimelineButtonIcon data={mockIndicator} />
</StoryProvidersComponent>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 {
generateMockIndicator,
generateMockUrlIndicator,
Indicator,
} from '../../../../../common/types/indicator';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { TestProvidersComponent } from '../../../../common/mocks/test_providers';
import { InvestigateInTimelineButtonIcon } from './investigate_in_timeline_button_icon';
describe('<InvestigateInTimelineButtonIcon />', () => {
it('should render button icon when Indicator data is correct', () => {
const mockData: Indicator = generateMockUrlIndicator();
const mockId = 'mockId';
const component = render(
<TestProvidersComponent>
<InvestigateInTimelineButtonIcon data={mockData} data-test-subj={mockId} />
</TestProvidersComponent>
);
expect(component.getByTestId(mockId)).toBeInTheDocument();
expect(component).toMatchSnapshot();
});
it(`should render empty component when calculated value is ${EMPTY_VALUE}`, () => {
const mockData: Indicator = generateMockIndicator();
mockData.fields['threat.indicator.first_seen'] = [''];
const component = render(
<TestProvidersComponent>
<InvestigateInTimelineButtonIcon data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,62 @@
/*
* 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, { VFC } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useInvestigateInTimeline } from '../../hooks/use_investigate_in_timeline';
import { Indicator } from '../../../../../common/types/indicator';
const BUTTON_LABEL: string = i18n.translate(
'xpack.threatIntelligence.investigateInTimelineButtonIcon',
{
defaultMessage: 'Investigate in Timeline',
}
);
export interface InvestigateInTimelineButtonIconProps {
/**
* Value passed to the timeline. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator;
/**
* Used for unit and e2e tests.
*/
['data-test-subj']?: string;
}
/**
* Investigate in timeline button, supports being passed a {@link Indicator}.
* This implementation uses the InvestigateInTimelineAction component (x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx)
* retrieved from the SecuritySolutionContext.
*
* @returns add to timeline button or an empty component.
*/
export const InvestigateInTimelineButtonIcon: VFC<InvestigateInTimelineButtonIconProps> = ({
data,
...props
}) => {
const { onClick } = useInvestigateInTimeline({ indicator: data });
if (!onClick) {
return <></>;
}
return (
<EuiToolTip content={BUTTON_LABEL}>
<EuiButtonIcon
aria-label={BUTTON_LABEL}
iconType="timeline"
iconSize="s"
size="xs"
color="primary"
onClick={onClick}
{...props}
/>
</EuiToolTip>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './use_investigate_in_timeline';

View file

@ -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, RenderHookResult, Renderer } from '@testing-library/react-hooks';
import {
useInvestigateInTimeline,
UseInvestigateInTimelineValue,
} from './use_investigate_in_timeline';
import {
generateMockIndicator,
generateMockUrlIndicator,
} from '../../../../common/types/indicator';
import { TestProvidersComponent } from '../../../common/mocks/test_providers';
describe('useInvestigateInTimeline()', () => {
let hookResult: RenderHookResult<{}, UseInvestigateInTimelineValue, Renderer<unknown>>;
it('should return empty object if Indicator is incorrect', () => {
const indicator = generateMockIndicator();
indicator.fields['threat.indicator.name'] = ['wrong'];
hookResult = renderHook(() => useInvestigateInTimeline({ indicator }), {
wrapper: TestProvidersComponent,
});
expect(hookResult.result.current).toEqual({});
});
it('should return ', () => {
const indicator = generateMockUrlIndicator();
hookResult = renderHook(() => useInvestigateInTimeline({ indicator }), {
wrapper: TestProvidersComponent,
});
expect(hookResult.result.current).toHaveProperty('onClick');
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { useContext } from 'react';
import moment from 'moment';
import { DataProvider } from '@kbn/timelines-plugin/common';
import { generateDataProvider } from '../lib/data_provider';
import { SecuritySolutionContext } from '../../../containers/security_solution_context';
import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../indicators/lib/field_value';
import { unwrapValue } from '../../indicators/lib/unwrap_value';
import {
Indicator,
IndicatorFieldEventEnrichmentMap,
RawIndicatorFieldId,
} from '../../../../common/types/indicator';
export interface UseInvestigateInTimelineParam {
/**
* Indicator used to retrieve the field and value then passed to the Investigate in Timeline logic
*/
indicator: Indicator;
}
export interface UseInvestigateInTimelineValue {
onClick: (() => Promise<void>) | undefined;
}
/**
* Custom hook that gets an {@link Indicator}, retrieves the field (from the RawIndicatorFieldId.Name)
* and value, then creates DataProviders used to do the Investigate in Timeline logic
* (see /kibana/x-pack/plugins/security_solution/public/threat_intelligence/use_investigate_in_timeline.ts)
*/
export const useInvestigateInTimeline = ({
indicator,
}: UseInvestigateInTimelineParam): UseInvestigateInTimelineValue => {
const securitySolutionContext = useContext(SecuritySolutionContext);
const { key, value } = getIndicatorFieldAndValue(indicator, RawIndicatorFieldId.Name);
if (!fieldAndValueValid(key, value)) {
return {} as unknown as UseInvestigateInTimelineValue;
}
const dataProviders: DataProvider[] = [...IndicatorFieldEventEnrichmentMap[key], key].map(
(e: string) => generateDataProvider(e, value as string)
);
const to = unwrapValue(indicator, RawIndicatorFieldId.TimeStamp) as string;
const from = moment(to).subtract(10, 'm').toISOString();
const investigateInTimelineClick = securitySolutionContext?.getUseInvestigateInTimeline({
dataProviders,
from,
to,
});
return {
onClick: investigateInTimelineClick,
};
};

View file

@ -0,0 +1,20 @@
/*
* 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 { generateDataProvider } from './data_provider';
describe('generateDataProvider', () => {
it('should return DataProvider object', () => {
const mockField: string = 'field';
const mockValue: string = 'value';
const dataProvider = generateDataProvider(mockField, mockValue);
expect(dataProvider.id).toContain(mockField);
expect(dataProvider.id).toContain(mockValue);
expect(dataProvider.name).toEqual(mockValue);
});
});

View file

@ -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 { DataProvider, QueryOperator } from '@kbn/timelines-plugin/common';
/**
* Generate a DataProvider object to use when adding/investigating to/in a timeline.
* @param field used to generate the DataProvider id as well as its queryMatch
* @param value used to generate the DataProvider id as well as its name and queryMatch
*/
export const generateDataProvider = (field: string, value: string): DataProvider => {
const operator = ':' as QueryOperator;
const id: string = `timeline-indicator-${field}-${value}`;
return {
and: [],
enabled: true,
id,
name: value,
excluded: false,
kqlQuery: '',
queryMatch: {
field,
value,
operator,
},
};
};

View file

@ -7,6 +7,7 @@
import { CoreStart, Plugin } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { Provider as ReduxStoreProvider } from 'react-redux';
import React, { Suspense, VFC } from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { KibanaContextProvider } from './hooks/use_kibana';
@ -46,15 +47,17 @@ export const createApp =
({ securitySolutionContext }: AppProps) =>
(
<IntlProvider>
<SecuritySolutionContext.Provider value={securitySolutionContext}>
<KibanaContextProvider services={services}>
<EnterpriseGuard>
<IntegrationsGuard>
<IndicatorsPage />
</IntegrationsGuard>
</EnterpriseGuard>
</KibanaContextProvider>
</SecuritySolutionContext.Provider>
<ReduxStoreProvider store={securitySolutionContext.getSecuritySolutionStore}>
<SecuritySolutionContext.Provider value={securitySolutionContext}>
<KibanaContextProvider services={services}>
<EnterpriseGuard>
<IntegrationsGuard>
<IndicatorsPage />
</IntegrationsGuard>
</EnterpriseGuard>
</KibanaContextProvider>
</SecuritySolutionContext.Provider>
</ReduxStoreProvider>
</IntlProvider>
);
@ -77,7 +80,9 @@ export class ThreatIntelligencePlugin implements Plugin<void, void> {
...plugins,
} as Services;
return { getComponent: createApp(services) };
return {
getComponent: createApp(services),
};
}
public stop() {}

View file

@ -18,6 +18,8 @@ import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
import { BrowserField } from '@kbn/triggers-actions-ui-plugin/public/application/sections/field_browser/types';
import { DataViewBase } from '@kbn/es-query';
import { Store } from 'redux';
import { DataProvider } from '@kbn/timelines-plugin/common';
export interface SecuritySolutionDataViewBase extends DataViewBase {
fields: Array<FieldSpec & DataViewField>;
@ -58,6 +60,12 @@ export interface SourcererDataView {
loading: boolean;
}
export interface UseInvestigateInTimelineProps {
dataProviders: DataProvider[];
from: string;
to: string;
}
/**
* Methods exposed from the security solution to the threat intelligence application.
*/
@ -77,7 +85,19 @@ export interface SecuritySolutionPluginContext {
*/
licenseService: LicenseAware;
/**
* Gets Security Solution shared information like browerFields, indexPattern and selectedPatterns in DataView
* Gets Security Solution shared information like browerFields, indexPattern and selectedPatterns in DataView.
*/
sourcererDataView: SourcererDataView;
/**
* Security Solution store
*/
getSecuritySolutionStore: Store;
/**
* Pass UseInvestigateInTimeline functionality to TI plugin
*/
getUseInvestigateInTimeline: ({
dataProviders,
from,
to,
}: UseInvestigateInTimelineProps) => () => Promise<void>;
}