mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Osquery] Add Detection action (#133279)
Adds Rule response action with osquery
This commit is contained in:
parent
208d1bf32f
commit
fe6060d22a
113 changed files with 2699 additions and 481 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
@ -581,6 +581,9 @@ x-pack/test/threat_intelligence_cypress @elastic/protections-experience
|
|||
|
||||
# Security Asset Management
|
||||
/x-pack/plugins/osquery @elastic/security-asset-management
|
||||
/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions @elastic/security-asset-management
|
||||
/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions @elastic/security-asset-management
|
||||
/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions @elastic/security-asset-management
|
||||
|
||||
# Cloud Security Posture
|
||||
/x-pack/plugins/cloud_security_posture/ @elastic/kibana-cloud-security-posture
|
||||
|
|
|
@ -196,8 +196,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
|
|||
'xpack.maps.preserveDrawingBuffer (boolean)',
|
||||
'xpack.maps.showMapsInspectorAdapter (boolean)',
|
||||
'xpack.osquery.actionEnabled (boolean)',
|
||||
'xpack.osquery.packs (boolean)',
|
||||
'xpack.osquery.savedQueries (boolean)',
|
||||
'xpack.remote_clusters.ui.enabled (boolean)',
|
||||
/**
|
||||
* NOTE: The Reporting plugin is currently disabled in functional tests (see test/functional/config.base.js).
|
||||
|
|
|
@ -10,8 +10,6 @@ import { schema } from '@kbn/config-schema';
|
|||
|
||||
export const ConfigSchema = schema.object({
|
||||
actionEnabled: schema.boolean({ defaultValue: false }),
|
||||
savedQueries: schema.boolean({ defaultValue: true }),
|
||||
packs: schema.boolean({ defaultValue: true }),
|
||||
});
|
||||
|
||||
export type ConfigType = TypeOf<typeof ConfigSchema>;
|
|
@ -85,7 +85,7 @@ export const arrayQueries = t.array(
|
|||
t.type({
|
||||
id,
|
||||
query,
|
||||
ecsMapping,
|
||||
ecs_mapping: ecsMapping,
|
||||
version,
|
||||
platform,
|
||||
})
|
||||
|
@ -95,7 +95,8 @@ export const objectQueries = t.record(
|
|||
t.string,
|
||||
t.type({
|
||||
query,
|
||||
ecsMapping: ecsMappingOrUndefined,
|
||||
id,
|
||||
ecs_mapping: ecsMappingOrUndefined,
|
||||
version: versionOrUndefined,
|
||||
platform: platformOrUndefined,
|
||||
saved_query_id: savedQueryIdOrUndefined,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty, reduce } from 'lodash';
|
||||
import { isEmpty, map, reduce } from 'lodash';
|
||||
import type { ECSMapping } from './schemas';
|
||||
|
||||
export const convertECSMappingToObject = (
|
||||
|
@ -30,3 +30,21 @@ export const convertECSMappingToObject = (
|
|||
},
|
||||
{} as Record<string, { field?: string; value?: string }>
|
||||
);
|
||||
|
||||
export type EcsMappingFormValueArray = Array<{
|
||||
key: string;
|
||||
result: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
}>;
|
||||
export const convertECSMappingToFormValue = (
|
||||
mapping?: Record<string, Record<'field', string>>
|
||||
): EcsMappingFormValueArray =>
|
||||
map(mapping, (value, key) => ({
|
||||
key,
|
||||
result: {
|
||||
type: Object.keys(value)[0],
|
||||
value: Object.values(value)[0],
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -13,22 +13,21 @@ import {
|
|||
packIdOrUndefined,
|
||||
queryOrUndefined,
|
||||
queriesOrUndefined,
|
||||
stringArrayOrUndefined,
|
||||
} from '../../common/schemas';
|
||||
|
||||
export const createLiveQueryRequestBodySchema = t.type({
|
||||
agent_ids: stringArrayOrUndefined,
|
||||
export const createLiveQueryRequestBodySchema = t.partial({
|
||||
agent_ids: t.array(t.string),
|
||||
agent_all: t.union([t.boolean, t.undefined]),
|
||||
agent_platforms: stringArrayOrUndefined,
|
||||
agent_policy_ids: stringArrayOrUndefined,
|
||||
agent_platforms: t.array(t.string),
|
||||
agent_policy_ids: t.array(t.string),
|
||||
query: queryOrUndefined,
|
||||
queries: queriesOrUndefined,
|
||||
saved_query_id: savedQueryIdOrUndefined,
|
||||
ecs_mapping: ecsMappingOrUndefined,
|
||||
pack_id: packIdOrUndefined,
|
||||
alert_ids: stringArrayOrUndefined,
|
||||
case_ids: stringArrayOrUndefined,
|
||||
event_ids: stringArrayOrUndefined,
|
||||
alert_ids: t.array(t.string),
|
||||
case_ids: t.array(t.string),
|
||||
event_ids: t.array(t.string),
|
||||
metadata: t.union([t.object, t.undefined]),
|
||||
});
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ export type ESQuery =
|
|||
| ESMatchQuery
|
||||
| ESTermQuery
|
||||
| ESBoolQuery
|
||||
| ESExistsQuery
|
||||
| JsonObject;
|
||||
|
||||
export interface ESRangeQuery {
|
||||
|
@ -47,6 +48,10 @@ export interface ESTermQuery {
|
|||
term: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ESExistsQuery {
|
||||
exists: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ESBoolQuery {
|
||||
bool: BoolQuery;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
|
||||
import { login } from '../../tasks/login';
|
||||
import {
|
||||
checkResults,
|
||||
findAndClickButton,
|
||||
findFormFieldByRowsLabelAndType,
|
||||
inputQuery,
|
||||
|
@ -16,10 +17,12 @@ import {
|
|||
import { preparePack } from '../../tasks/packs';
|
||||
import { closeModalIfVisible } from '../../tasks/integrations';
|
||||
import { navigateTo } from '../../tasks/navigation';
|
||||
import { RESULTS_TABLE, RESULTS_TABLE_BUTTON } from '../../screens/live_query';
|
||||
import { LIVE_QUERY_EDITOR, RESULTS_TABLE, RESULTS_TABLE_BUTTON } from '../../screens/live_query';
|
||||
import { ROLES } from '../../test';
|
||||
|
||||
describe('Alert Event Details', () => {
|
||||
const RULE_NAME = 'Test-rule';
|
||||
|
||||
before(() => {
|
||||
runKbnArchiverScript(ArchiverMethod.LOAD, 'pack');
|
||||
runKbnArchiverScript(ArchiverMethod.LOAD, 'rule');
|
||||
|
@ -35,7 +38,6 @@ describe('Alert Event Details', () => {
|
|||
|
||||
it('should prepare packs and alert rules', () => {
|
||||
const PACK_NAME = 'testpack';
|
||||
const RULE_NAME = 'Test-rule';
|
||||
navigateTo('/app/osquery/packs');
|
||||
preparePack(PACK_NAME);
|
||||
findAndClickButton('Edit');
|
||||
|
@ -93,4 +95,37 @@ describe('Alert Event Details', () => {
|
|||
cy.contains(TIMELINE_NAME).click();
|
||||
cy.getBySel('draggableWrapperKeyboardHandler').contains('action_id: "');
|
||||
});
|
||||
it('enables to add detection action with osquery', () => {
|
||||
cy.visit('/app/security/rules');
|
||||
cy.contains(RULE_NAME).click();
|
||||
cy.contains('Edit rule settings').click();
|
||||
cy.getBySel('edit-rule-actions-tab').wait(500).click();
|
||||
cy.contains('Perform no actions').get('select').select('On each rule execution');
|
||||
cy.contains('Response actions are run on each rule execution');
|
||||
cy.getBySel('.osquery-ResponseActionTypeSelectOption').click();
|
||||
cy.get(LIVE_QUERY_EDITOR);
|
||||
cy.contains('Save changes').click();
|
||||
cy.contains('Query is a required field');
|
||||
inputQuery('select * from uptime');
|
||||
cy.wait(1000); // wait for the validation to trigger - cypress is way faster than users ;)
|
||||
|
||||
// getSavedQueriesDropdown().type(`users{downArrow}{enter}`);
|
||||
cy.contains('Save changes').click();
|
||||
cy.contains(`${RULE_NAME} was saved`).should('exist');
|
||||
cy.contains('Edit rule settings').click();
|
||||
cy.getBySel('edit-rule-actions-tab').wait(500).click();
|
||||
cy.contains('select * from uptime');
|
||||
});
|
||||
// TODO think on how to get these actions triggered faster (because now they are not triggered during the test).
|
||||
it.skip('sees osquery results from last action', () => {
|
||||
cy.visit('/app/security/alerts');
|
||||
cy.getBySel('header-page-title').contains('Alerts').should('exist');
|
||||
cy.getBySel('expand-event').first().click({ force: true });
|
||||
cy.contains('Osquery Results').click();
|
||||
cy.getBySel('osquery-results').should('exist');
|
||||
cy.contains('select * from uptime');
|
||||
cy.getBySel('osqueryResultsTable').within(() => {
|
||||
checkResults();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -52,6 +52,11 @@ const ActionsTableComponent = () => {
|
|||
limit: pageSize,
|
||||
direction: Direction.desc,
|
||||
sortField: '@timestamp',
|
||||
filterQuery: {
|
||||
exists: {
|
||||
field: 'user_id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onTableChange = useCallback(({ page = {} }) => {
|
||||
|
|
|
@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { createFilter } from '../common/helpers';
|
||||
import { useKibana } from '../common/lib/kibana';
|
||||
import type { ActionEdges, ActionsStrategyResponse, Direction } from '../../common/search_strategy';
|
||||
import type { ESTermQuery } from '../../common/typed_json';
|
||||
import type { ESTermQuery, ESExistsQuery } from '../../common/typed_json';
|
||||
|
||||
import { useErrorToast } from '../common/hooks/use_error_toast';
|
||||
|
||||
|
@ -20,7 +20,7 @@ interface UseAllLiveQueries {
|
|||
direction: Direction;
|
||||
limit: number;
|
||||
sortField: string;
|
||||
filterQuery?: ESTermQuery | string;
|
||||
filterQuery?: ESTermQuery | ESExistsQuery | string;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { filter } from 'lodash';
|
||||
import type { EcsMappingFormField } from '../packs/queries/ecs_mapping_editor_field';
|
||||
import { useKibana } from '../common/lib/kibana';
|
||||
import type { ESTermQuery } from '../../common/typed_json';
|
||||
import { useErrorToast } from '../common/hooks/use_error_toast';
|
||||
|
@ -21,6 +22,18 @@ interface UseLiveQueryDetails {
|
|||
queryIds?: string[];
|
||||
}
|
||||
|
||||
export interface PackQueriesQuery {
|
||||
action_id: string;
|
||||
id: string;
|
||||
query: string;
|
||||
agents: string[];
|
||||
ecs_mapping?: EcsMappingFormField[];
|
||||
version?: string;
|
||||
platform?: string;
|
||||
saved_query_id?: string;
|
||||
expiration?: string;
|
||||
}
|
||||
|
||||
export interface LiveQueryDetailsItem {
|
||||
action_id: string;
|
||||
expiration: string;
|
||||
|
@ -35,17 +48,7 @@ export interface LiveQueryDetailsItem {
|
|||
pack_name?: string;
|
||||
pack_prebuilt?: boolean;
|
||||
status?: string;
|
||||
queries?: Array<{
|
||||
action_id: string;
|
||||
id: string;
|
||||
query: string;
|
||||
agents: string[];
|
||||
ecs_mapping?: unknown;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
saved_query_id?: string;
|
||||
expiration?: string;
|
||||
}>;
|
||||
queries?: PackQueriesQuery[];
|
||||
}
|
||||
|
||||
export const useLiveQueryDetails = ({
|
||||
|
|
|
@ -35,9 +35,16 @@ export const AGENT_POLICY_LABEL = i18n.translate('xpack.osquery.agents.policyLab
|
|||
defaultMessage: `Policy`,
|
||||
});
|
||||
|
||||
export const AGENT = i18n.translate('xpack.osquery.agents.agent', {
|
||||
defaultMessage: `Agent`,
|
||||
});
|
||||
|
||||
export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.selectionLabel', {
|
||||
defaultMessage: `Agents`,
|
||||
});
|
||||
export const AGENT_QUERY = i18n.translate('xpack.osquery.agents.query', {
|
||||
defaultMessage: `Query`,
|
||||
});
|
||||
|
||||
export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', {
|
||||
defaultMessage: `Select agents or groups to query`,
|
||||
|
@ -50,3 +57,7 @@ export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearch
|
|||
export const FAIL_ALL_AGENTS = i18n.translate('xpack.osquery.agents.failSearchDescription', {
|
||||
defaultMessage: `Failed to fetch agents`,
|
||||
});
|
||||
|
||||
export const ATTACHED_QUERY = i18n.translate('xpack.osquery.agent.attachedQuery', {
|
||||
defaultMessage: `attached query`, // as in 'User attached query 5 minutes ago'
|
||||
});
|
||||
|
|
|
@ -13,7 +13,7 @@ interface QueryDescriptionFieldProps {
|
|||
euiFieldProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const QueryDescriptionFieldComponentn = ({ euiFieldProps }: QueryDescriptionFieldProps) => {
|
||||
const QueryDescriptionFieldComponent = ({ euiFieldProps }: QueryDescriptionFieldProps) => {
|
||||
const {
|
||||
field: { onChange, value, name: fieldName },
|
||||
fieldState: { error },
|
||||
|
@ -46,4 +46,4 @@ const QueryDescriptionFieldComponentn = ({ euiFieldProps }: QueryDescriptionFiel
|
|||
);
|
||||
};
|
||||
|
||||
export const QueryDescriptionField = React.memo(QueryDescriptionFieldComponentn);
|
||||
export const QueryDescriptionField = React.memo(QueryDescriptionFieldComponent);
|
||||
|
|
|
@ -15,7 +15,7 @@ interface QueryIdFieldProps {
|
|||
euiFieldProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const QueryIdFieldComponentn = ({ idSet, euiFieldProps }: QueryIdFieldProps) => {
|
||||
const QueryIdFieldComponent = ({ idSet, euiFieldProps }: QueryIdFieldProps) => {
|
||||
const {
|
||||
field: { onChange, value, name: fieldName },
|
||||
fieldState: { error },
|
||||
|
@ -49,4 +49,4 @@ const QueryIdFieldComponentn = ({ idSet, euiFieldProps }: QueryIdFieldProps) =>
|
|||
);
|
||||
};
|
||||
|
||||
export const QueryIdField = React.memo(QueryIdFieldComponentn);
|
||||
export const QueryIdField = React.memo(QueryIdFieldComponent);
|
||||
|
|
|
@ -5,23 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFormRow } from '@elastic/eui';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiCard,
|
||||
} from '@elastic/eui';
|
||||
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useForm as useHookForm, FormProvider } from 'react-hook-form';
|
||||
import { isEmpty, map, find, pickBy } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import type { AddToTimelinePayload } from '../../timelines/get_add_to_timeline';
|
||||
import { QueryPackSelectable } from './query_pack_selectable';
|
||||
import type { SavedQuerySOFormData } from '../../saved_queries/form/use_saved_query_form';
|
||||
import type {
|
||||
EcsMappingFormField,
|
||||
|
@ -33,15 +24,14 @@ import { useKibana } from '../../common/lib/kibana';
|
|||
import { ResultTabs } from '../../routes/saved_queries/edit/tabs';
|
||||
import { SavedQueryFlyout } from '../../saved_queries';
|
||||
import { usePacks } from '../../packs/use_packs';
|
||||
import { PackQueriesStatusTable } from './pack_queries_status_table';
|
||||
import { useCreateLiveQuery } from '../use_create_live_query_action';
|
||||
import { useLiveQueryDetails } from '../../actions/use_live_query_details';
|
||||
import type { AgentSelection } from '../../agents/types';
|
||||
import { LiveQueryQueryField } from './live_query_query_field';
|
||||
import { AgentsTableField } from './agents_table_field';
|
||||
import { PacksComboBoxField } from './packs_combobox_field';
|
||||
import { savedQueryDataSerializer } from '../../saved_queries/form/use_saved_query_form';
|
||||
import { AddToCaseButton } from '../../cases/add_to_cases_button';
|
||||
import { PackFieldWrapper } from '../../shared_components/osquery_response_action_type/pack_field_wrapper';
|
||||
|
||||
export interface LiveQueryFormFields {
|
||||
query?: string;
|
||||
|
@ -59,44 +49,6 @@ interface DefaultLiveQueryFormFields {
|
|||
packId?: string;
|
||||
}
|
||||
|
||||
const StyledEuiCard = styled(EuiCard)`
|
||||
padding: 16px 92px 16px 16px !important;
|
||||
|
||||
.euiTitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.euiText {
|
||||
margin-top: 0;
|
||||
color: ${(props) => props.theme.eui.euiTextSubduedColor};
|
||||
}
|
||||
|
||||
> button[role='switch'] {
|
||||
left: auto;
|
||||
height: 100% !important;
|
||||
width: 80px;
|
||||
right: 0;
|
||||
border-radius: 0 5px 5px 0;
|
||||
|
||||
> span {
|
||||
> svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
// hide the label
|
||||
> :not(svg) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button[aria-checked='false'] > span > svg {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type FormType = 'simple' | 'steps';
|
||||
|
||||
interface LiveQueryFormProps {
|
||||
|
@ -174,15 +126,10 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
isLive,
|
||||
});
|
||||
|
||||
const actionId = useMemo(() => liveQueryDetails?.action_id, [liveQueryDetails?.action_id]);
|
||||
const agentIds = useMemo(() => liveQueryDetails?.agents, [liveQueryDetails?.agents]);
|
||||
|
||||
useEffect(() => {
|
||||
register('savedQueryId');
|
||||
}, [register]);
|
||||
|
||||
const { packId } = watchedValues;
|
||||
|
||||
const queryStatus = useMemo(() => {
|
||||
if (isError || queryState.invalid) return 'danger';
|
||||
if (isLoading) return 'loading';
|
||||
|
@ -228,11 +175,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
|
||||
const { data: packsData, isFetched: isPackDataFetched } = usePacks({});
|
||||
|
||||
const selectedPackData = useMemo(
|
||||
() => (packId?.length ? find(packsData?.data, { id: packId[0] }) : null),
|
||||
[packId, packsData]
|
||||
);
|
||||
|
||||
const handleSubmitForm = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]);
|
||||
|
||||
const submitButtonContent = useMemo(
|
||||
|
@ -281,6 +223,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
|
||||
const singleQueryDetails = useMemo(() => liveQueryDetails?.queries?.[0], [liveQueryDetails]);
|
||||
const liveQueryActionId = useMemo(() => liveQueryDetails?.action_id, [liveQueryDetails]);
|
||||
const agentIds = useMemo(() => liveQueryDetails?.agents, [liveQueryDetails?.agents]);
|
||||
|
||||
const addToCaseButton = useCallback(
|
||||
(payload) => {
|
||||
|
@ -370,24 +313,6 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
}
|
||||
}, [canRunPacks, canRunSingleQuery, defaultValue, isPackDataFetched, packsData?.data, setValue]);
|
||||
|
||||
const queryCardSelectable = useMemo(
|
||||
() => ({
|
||||
onClick: () => setQueryType('query'),
|
||||
isSelected: queryType === 'query',
|
||||
iconType: 'check',
|
||||
}),
|
||||
[queryType]
|
||||
);
|
||||
|
||||
const packCardSelectable = useMemo(
|
||||
() => ({
|
||||
onClick: () => setQueryType('pack'),
|
||||
isSelected: queryType === 'pack',
|
||||
iconType: 'check',
|
||||
}),
|
||||
[queryType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLive(() => !(liveQueryDetails?.status === 'completed'));
|
||||
}, [liveQueryDetails?.status]);
|
||||
|
@ -408,54 +333,12 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
<FormProvider {...hooksForm}>
|
||||
<EuiFlexGroup direction="column">
|
||||
{queryField && (
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow label="Query type" fullWidth>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<StyledEuiCard
|
||||
layout="horizontal"
|
||||
title={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.singleQueryTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Single query',
|
||||
}
|
||||
)}
|
||||
titleSize="xs"
|
||||
hasBorder
|
||||
description={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.singleQueryTypeDescription',
|
||||
{
|
||||
defaultMessage: 'Run a saved query or new one.',
|
||||
}
|
||||
)}
|
||||
selectable={queryCardSelectable}
|
||||
isDisabled={!canRunSingleQuery}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<StyledEuiCard
|
||||
layout="horizontal"
|
||||
title={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.packQueryTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Pack',
|
||||
}
|
||||
)}
|
||||
titleSize="xs"
|
||||
hasBorder
|
||||
description={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.packQueryTypeDescription',
|
||||
{
|
||||
defaultMessage: 'Run a set of queries in a pack.',
|
||||
}
|
||||
)}
|
||||
selectable={packCardSelectable}
|
||||
isDisabled={!canRunPacks}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<QueryPackSelectable
|
||||
queryType={queryType}
|
||||
setQueryType={setQueryType}
|
||||
canRunPacks={canRunPacks}
|
||||
canRunSingleQuery={canRunSingleQuery}
|
||||
/>
|
||||
)}
|
||||
{!hideAgentsField && (
|
||||
<EuiFlexItem>
|
||||
|
@ -463,34 +346,13 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
|
|||
</EuiFlexItem>
|
||||
)}
|
||||
{queryType === 'pack' ? (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
<PacksComboBoxField
|
||||
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
|
||||
fieldProps={{ packsData: packsData?.data }}
|
||||
queryType={queryType}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{submitButtonContent}
|
||||
<EuiSpacer />
|
||||
|
||||
{liveQueryDetails?.queries?.length ||
|
||||
selectedPackData?.attributes?.queries?.length ? (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
<PackQueriesStatusTable
|
||||
actionId={actionId}
|
||||
agentIds={agentIds}
|
||||
// @ts-expect-error version string !+ string[]
|
||||
data={liveQueryDetails?.queries ?? selectedPackData?.attributes?.queries}
|
||||
addToCase={addToCaseButton}
|
||||
addToTimeline={addToTimeline}
|
||||
showResultsHeader
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
<PackFieldWrapper
|
||||
liveQueryDetails={liveQueryDetails}
|
||||
addToTimeline={addToTimeline}
|
||||
submitButtonContent={submitButtonContent}
|
||||
addToCase={addToCaseButton}
|
||||
showResultsHeader
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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 { EuiCard, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledEuiCard = styled(EuiCard)`
|
||||
padding: 16px 92px 16px 16px !important;
|
||||
|
||||
.euiTitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.euiText {
|
||||
margin-top: 0;
|
||||
color: ${(props) => props.theme.eui.euiTextSubduedColor};
|
||||
}
|
||||
|
||||
> button[role='switch'] {
|
||||
left: auto;
|
||||
height: 100% !important;
|
||||
width: 80px;
|
||||
right: 0;
|
||||
border-radius: 0 5px 5px 0;
|
||||
|
||||
> span {
|
||||
> svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
// hide the label
|
||||
> :not(svg) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button[aria-checked='false'] > span > svg {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface QueryPackSelectableProps {
|
||||
queryType: string;
|
||||
setQueryType: (type: string) => void;
|
||||
canRunSingleQuery: boolean;
|
||||
canRunPacks: boolean;
|
||||
resetFormFields?: () => void;
|
||||
}
|
||||
|
||||
export const QueryPackSelectable = ({
|
||||
queryType,
|
||||
setQueryType,
|
||||
canRunSingleQuery,
|
||||
canRunPacks,
|
||||
resetFormFields,
|
||||
}: QueryPackSelectableProps) => {
|
||||
const handleChange = useCallback(
|
||||
(type) => {
|
||||
setQueryType(type);
|
||||
if (resetFormFields) {
|
||||
resetFormFields();
|
||||
}
|
||||
},
|
||||
[resetFormFields, setQueryType]
|
||||
);
|
||||
const queryCardSelectable = useMemo(
|
||||
() => ({
|
||||
onClick: () => handleChange('query'),
|
||||
isSelected: queryType === 'query',
|
||||
iconType: 'check',
|
||||
}),
|
||||
[queryType, handleChange]
|
||||
);
|
||||
|
||||
const packCardSelectable = useMemo(
|
||||
() => ({
|
||||
onClick: () => handleChange('pack'),
|
||||
isSelected: queryType === 'pack',
|
||||
iconType: 'check',
|
||||
}),
|
||||
[queryType, handleChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow label="Query type" fullWidth>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<StyledEuiCard
|
||||
layout="horizontal"
|
||||
title={i18n.translate('xpack.osquery.liveQuery.queryForm.singleQueryTypeLabel', {
|
||||
defaultMessage: 'Single query',
|
||||
})}
|
||||
titleSize="xs"
|
||||
hasBorder
|
||||
description={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.singleQueryTypeDescription',
|
||||
{
|
||||
defaultMessage: 'Run a saved query or new one.',
|
||||
}
|
||||
)}
|
||||
selectable={queryCardSelectable}
|
||||
isDisabled={!canRunSingleQuery}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<StyledEuiCard
|
||||
layout="horizontal"
|
||||
title={i18n.translate('xpack.osquery.liveQuery.queryForm.packQueryTypeLabel', {
|
||||
defaultMessage: 'Pack',
|
||||
})}
|
||||
titleSize="xs"
|
||||
hasBorder
|
||||
description={i18n.translate(
|
||||
'xpack.osquery.liveQuery.queryForm.packQueryTypeDescription',
|
||||
{
|
||||
defaultMessage: 'Run a set of queries in a pack.',
|
||||
}
|
||||
)}
|
||||
selectable={packCardSelectable}
|
||||
isDisabled={!canRunPacks}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -21,7 +21,6 @@ import {
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import {
|
||||
EuiFormLabel,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -127,12 +126,12 @@ const DescriptionWrapper = styled(EuiFlexItem)`
|
|||
|
||||
// align the icon to the inputs
|
||||
const StyledSemicolonWrapper = styled.div`
|
||||
margin-top: 8px;
|
||||
margin-top: 28px;
|
||||
`;
|
||||
|
||||
// align the icon to the inputs
|
||||
const StyledButtonWrapper = styled.div`
|
||||
margin-top: 11px;
|
||||
margin-top: 28px;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
|
@ -263,6 +262,9 @@ const ECSComboboxFieldComponent: React.FC<ECSComboboxFieldProps> = ({
|
|||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.osquery.pack.queryFlyoutForm.mappingEcsFieldLabel', {
|
||||
defaultMessage: 'ECS field',
|
||||
})}
|
||||
helpText={helpText}
|
||||
error={error}
|
||||
isInvalid={!!error}
|
||||
|
@ -534,6 +536,9 @@ const OsqueryColumnFieldComponent: React.FC<OsqueryColumnFieldProps> = ({
|
|||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.osquery.pack.queryFlyoutForm.mappingValueFieldLabel', {
|
||||
defaultMessage: 'Value',
|
||||
})}
|
||||
helpText={selectedOptions[0]?.value?.description}
|
||||
error={resultFieldState.error?.message}
|
||||
isInvalid={!!resultFieldState.error?.message?.length}
|
||||
|
@ -1011,25 +1016,6 @@ export const ECSMappingEditorField = React.memo(
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
<FormattedMessage
|
||||
id="xpack.osquery.pack.queryFlyoutForm.mappingEcsFieldLabel"
|
||||
defaultMessage="ECS field"
|
||||
/>
|
||||
</EuiFormLabel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
<FormattedMessage
|
||||
id="xpack.osquery.pack.queryFlyoutForm.mappingValueFieldLabel"
|
||||
defaultMessage="Value"
|
||||
/>
|
||||
</EuiFormLabel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{fields.map((item, index, array) => {
|
||||
itemsList.current = array;
|
||||
|
|
|
@ -22,9 +22,10 @@ import { i18n } from '@kbn/i18n';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
|
||||
import { isEmpty, map } from 'lodash';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { QueryIdField, IntervalField } from '../../form';
|
||||
import { defaultEcsFormData } from './ecs_mapping_editor_field';
|
||||
import { convertECSMappingToFormValue } from '../../../common/schemas/common/utils';
|
||||
import { CodeEditorField } from '../../saved_queries/form/code_editor_field';
|
||||
import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field';
|
||||
import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants';
|
||||
|
@ -84,13 +85,7 @@ const QueryFlyoutComponent: React.FC<QueryFlyoutProps> = ({
|
|||
setValue(
|
||||
'ecs_mapping',
|
||||
!isEmpty(savedQuery.ecs_mapping)
|
||||
? map(savedQuery.ecs_mapping, (value, key) => ({
|
||||
key,
|
||||
result: {
|
||||
type: Object.keys(value)[0],
|
||||
value: Object.values(value)[0] as string,
|
||||
},
|
||||
}))
|
||||
? convertECSMappingToFormValue(savedQuery.ecs_mapping)
|
||||
: [defaultEcsFormData]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useKibana } from '../common/lib/kibana';
|
|||
import type { PackItem } from './types';
|
||||
|
||||
interface UsePack {
|
||||
packId: string;
|
||||
packId?: string;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ export const usePack = ({ packId, skip = false }: UsePack) => {
|
|||
{
|
||||
select: (response) => response?.data,
|
||||
keepPreviousData: true,
|
||||
enabled: !skip || !packId,
|
||||
enabled: !!(!skip && packId),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,6 +14,9 @@ import type {
|
|||
} from '@kbn/core/public';
|
||||
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { getLazyOsqueryResponseActionTypeForm } from './shared_components/lazy_osquery_action_params_form';
|
||||
import { useFetchStatus } from './fleet_integration/use_fetch_status';
|
||||
import { getLazyOsqueryResults } from './shared_components/lazy_osquery_results';
|
||||
import type {
|
||||
OsqueryPluginSetup,
|
||||
OsqueryPluginStart,
|
||||
|
@ -46,6 +49,7 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
|
|||
public setup(core: CoreSetup, plugins: SetupPlugins): OsqueryPluginSetup {
|
||||
const storage = this.storage;
|
||||
const kibanaVersion = this.kibanaVersion;
|
||||
|
||||
// Register an application into the side navigation menu
|
||||
core.application.register({
|
||||
id: 'osquery',
|
||||
|
@ -117,6 +121,14 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
|
|||
...core,
|
||||
...plugins,
|
||||
}),
|
||||
OsqueryResults: getLazyOsqueryResults({
|
||||
...core,
|
||||
...plugins,
|
||||
storage: this.storage,
|
||||
kibanaVersion: this.kibanaVersion,
|
||||
}),
|
||||
OsqueryResponseActionTypeForm: getLazyOsqueryResponseActionTypeForm(),
|
||||
fetchInstallationStatus: useFetchStatus,
|
||||
isOsqueryAvailable: useIsOsqueryAvailableSimple,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ import { OSQUERY_INTEGRATION_NAME } from '../../common';
|
|||
|
||||
const DataContext = createContext<ResultEdges>([]);
|
||||
|
||||
interface ResultsTableComponentProps {
|
||||
export interface ResultsTableComponentProps {
|
||||
actionId: string;
|
||||
selectedAgent?: string;
|
||||
agentIds?: string[];
|
||||
|
|
|
@ -14,6 +14,7 @@ import type { ECSMapping } from '../../../common/schemas/common';
|
|||
import { convertECSMappingToObject } from '../../../common/schemas/common/utils';
|
||||
import type { EcsMappingFormField } from '../../packs/queries/ecs_mapping_editor_field';
|
||||
import { defaultEcsFormData } from '../../packs/queries/ecs_mapping_editor_field';
|
||||
|
||||
import { useSavedQueries } from '../use_saved_queries';
|
||||
|
||||
export interface SavedQuerySOFormData {
|
||||
|
@ -96,7 +97,14 @@ export const useSavedQueryForm = ({ defaultValue }: UseSavedQueryFormProps) => {
|
|||
serializer: savedQueryDataSerializer,
|
||||
idSet,
|
||||
...useHookForm<SavedQueryFormData>({
|
||||
defaultValues: defaultValue ? deserializer(defaultValue) : {},
|
||||
defaultValues: defaultValue
|
||||
? deserializer(defaultValue)
|
||||
: {
|
||||
id: '',
|
||||
query: '',
|
||||
interval: 3600,
|
||||
ecs_mapping: [defaultEcsFormData],
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -9,7 +9,7 @@ import { find } from 'lodash/fp';
|
|||
import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiTextColor } from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import { QUERIES_DROPDOWN_LABEL, QUERIES_DROPDOWN_SEARCH_FIELD_LABEL } from './constants';
|
||||
import { OsquerySchemaLink } from '../components/osquery_schema_link';
|
||||
|
||||
|
@ -50,6 +50,9 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const savedQueryId = useWatch({ name: 'savedQueryId' });
|
||||
const context = useFormContext();
|
||||
const { errors } = context.formState;
|
||||
const queryFieldError = errors?.query?.message;
|
||||
const [selectedOptions, setSelectedOptions] = useState<SelectedOption[]>([]);
|
||||
|
||||
const { data } = useSavedQueries({});
|
||||
|
@ -125,11 +128,14 @@ const SavedQueriesDropdownComponent: React.FC<SavedQueriesDropdownProps> = ({
|
|||
|
||||
return (
|
||||
<EuiFormRow
|
||||
isInvalid={!!queryFieldError}
|
||||
error={queryFieldError}
|
||||
label={QUERIES_DROPDOWN_SEARCH_FIELD_LABEL}
|
||||
labelAppend={<OsquerySchemaLink />}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
data-test-subj={'savedQuerySelect'}
|
||||
isDisabled={disabled}
|
||||
fullWidth
|
||||
placeholder={QUERIES_DROPDOWN_LABEL}
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { getLazyOsqueryResults } from './lazy_osquery_results';
|
||||
export { getLazyOsqueryAction } from './lazy_osquery_action';
|
||||
export { getLazyLiveQueryField } from './lazy_live_query_field';
|
||||
export { useIsOsqueryAvailableSimple } from './osquery_action/use_is_osquery_available_simple';
|
||||
export { getLazyOsqueryResponseActionTypeForm } from './lazy_osquery_action_params_form';
|
||||
export { getExternalReferenceAttachmentRegular } from './attachments/external_reference';
|
||||
|
|
|
@ -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, { lazy, Suspense } from 'react';
|
||||
import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '../query_client';
|
||||
|
||||
interface LazyOsqueryActionParamsFormProps {
|
||||
item: ArrayItem;
|
||||
formRef: React.RefObject<ResponseActionValidatorRef>;
|
||||
}
|
||||
interface ResponseActionValidatorRef {
|
||||
validation: {
|
||||
[key: string]: () => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
const GhostFormField = () => <></>;
|
||||
|
||||
export const getLazyOsqueryResponseActionTypeForm =
|
||||
// eslint-disable-next-line react/display-name
|
||||
() => (props: LazyOsqueryActionParamsFormProps) => {
|
||||
const { item, formRef } = props;
|
||||
const OsqueryResponseActionParamsForm = lazy(() => import('./osquery_response_action_type'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<UseField
|
||||
path={`${item.path}.params`}
|
||||
component={GhostFormField}
|
||||
readDefaultValueOnForm={!item.isNew}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OsqueryResponseActionParamsForm item={item} ref={formRef} />
|
||||
</QueryClientProvider>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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, { lazy, Suspense } from 'react';
|
||||
import type { OsqueryActionResultsProps } from './osquery_results/types';
|
||||
import type { StartServices } from '../types';
|
||||
|
||||
interface BigServices extends StartServices {
|
||||
kibanaVersion: string;
|
||||
storage: unknown;
|
||||
}
|
||||
|
||||
export const getLazyOsqueryResults =
|
||||
// eslint-disable-next-line react/display-name
|
||||
(services: BigServices) => (props: OsqueryActionResultsProps) => {
|
||||
const OsqueryResults = lazy(() => import('./osquery_results'));
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<OsqueryResults services={services} {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
|
@ -8,6 +8,7 @@
|
|||
import { EuiLoadingContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { AddToTimelinePayload } from '../../timelines/get_add_to_timeline';
|
||||
import {
|
||||
AGENT_STATUS_ERROR,
|
||||
|
@ -19,7 +20,7 @@ import {
|
|||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { LiveQuery } from '../../live_queries';
|
||||
import { OsqueryIcon } from '../../components/osquery_icon';
|
||||
import { useIsOsqueryAvailable } from './use_is_osquery_available';
|
||||
import { useIsOsqueryAvailable } from '../use_is_osquery_available';
|
||||
|
||||
export interface OsqueryActionProps {
|
||||
agentId?: string;
|
||||
|
@ -67,8 +68,14 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
|
|||
titleSize="xs"
|
||||
body={
|
||||
<p>
|
||||
To access this page, ask your administrator for <EuiCode>osquery</EuiCode> Kibana
|
||||
privileges.
|
||||
<FormattedMessage
|
||||
id="xpack.osquery.action.missingPrivilleges"
|
||||
defaultMessage="To access this page, ask your administrator for {osquery} Kibana privileges."
|
||||
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
|
||||
values={{
|
||||
osquery: <EuiCode>osquery</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -12,7 +12,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||
|
||||
import { OsqueryAction } from '.';
|
||||
import { queryClient } from '../../query_client';
|
||||
import * as hooks from './use_is_osquery_available';
|
||||
import * as hooks from '../use_is_osquery_available';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { AGENT_STATUS_ERROR, EMPTY_PROMPT, NOT_AVAILABLE, PERMISSION_DENIED } from './translations';
|
||||
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
* 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, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import uuid from 'uuid';
|
||||
import { useForm as useHookForm, FormProvider } from 'react-hook-form';
|
||||
import { get, isEmpty, map } from 'lodash';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
|
||||
import {
|
||||
convertECSMappingToFormValue,
|
||||
convertECSMappingToObject,
|
||||
} from '../../../common/schemas/common/utils';
|
||||
import { QueryPackSelectable } from '../../live_queries/form/query_pack_selectable';
|
||||
import type { EcsMappingFormField } from '../../packs/queries/ecs_mapping_editor_field';
|
||||
import { defaultEcsFormData } from '../../packs/queries/ecs_mapping_editor_field';
|
||||
import { useFormContext } from '../../shared_imports';
|
||||
import type { ArrayItem } from '../../shared_imports';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { LiveQueryQueryField } from '../../live_queries/form/live_query_query_field';
|
||||
import { PackFieldWrapper } from './pack_field_wrapper';
|
||||
import { usePack } from '../../packs/use_pack';
|
||||
|
||||
const OSQUERY_TYPE = '.osquery';
|
||||
|
||||
interface OsqueryResponseActionsParamsFormProps {
|
||||
item: ArrayItem;
|
||||
}
|
||||
|
||||
interface ResponseActionValidatorRef {
|
||||
validation: {
|
||||
[key: string]: () => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OsqueryResponseActionsParamsFormFields {
|
||||
savedQueryId: string | null;
|
||||
id: string;
|
||||
ecs_mapping: EcsMappingFormField[];
|
||||
query: string;
|
||||
packId?: string[];
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
ecs_mapping: EcsMappingFormField[];
|
||||
query: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const OsqueryResponseActionParamsFormComponent: React.ForwardRefExoticComponent<
|
||||
React.PropsWithoutRef<OsqueryResponseActionsParamsFormProps> &
|
||||
React.RefAttributes<ResponseActionValidatorRef>
|
||||
> = forwardRef(({ item }, ref) => {
|
||||
const uniqueId = useMemo(() => uuid.v4(), []);
|
||||
const hooksForm = useHookForm<OsqueryResponseActionsParamsFormFields>({
|
||||
defaultValues: {
|
||||
ecs_mapping: [defaultEcsFormData],
|
||||
id: uniqueId,
|
||||
},
|
||||
});
|
||||
//
|
||||
const { watch, setValue, register, clearErrors, formState, handleSubmit } = hooksForm;
|
||||
const { errors, isValid } = formState;
|
||||
const context = useFormContext();
|
||||
const data = context.getFormData();
|
||||
const { params: defaultParams } = get(data, item.path);
|
||||
|
||||
const watchedValues = watch();
|
||||
const { data: packData } = usePack({
|
||||
packId: watchedValues?.packId?.[0],
|
||||
skip: !watchedValues?.packId?.[0],
|
||||
});
|
||||
const [queryType, setQueryType] = useState<string>(
|
||||
!isEmpty(defaultParams?.queries) ? 'pack' : 'query'
|
||||
);
|
||||
const onSubmit = useCallback(async () => {
|
||||
try {
|
||||
if (queryType === 'pack') {
|
||||
context.updateFieldValues({
|
||||
[item.path]: {
|
||||
actionTypeId: OSQUERY_TYPE,
|
||||
params: {
|
||||
id: watchedValues.id,
|
||||
packId: watchedValues?.packId?.length ? watchedValues?.packId[0] : undefined,
|
||||
queries: packData
|
||||
? map(packData.queries, (query, queryId: string) => ({
|
||||
...query,
|
||||
id: queryId,
|
||||
}))
|
||||
: watchedValues.queries,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
context.updateFieldValues({
|
||||
[item.path]: {
|
||||
actionTypeId: OSQUERY_TYPE,
|
||||
params: {
|
||||
id: watchedValues.id,
|
||||
savedQueryId: watchedValues.savedQueryId,
|
||||
query: watchedValues.query,
|
||||
ecs_mapping: convertECSMappingToObject(watchedValues.ecs_mapping),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
}, [
|
||||
context,
|
||||
item.path,
|
||||
packData,
|
||||
queryType,
|
||||
watchedValues.ecs_mapping,
|
||||
watchedValues.id,
|
||||
watchedValues?.packId,
|
||||
watchedValues.queries,
|
||||
watchedValues.query,
|
||||
watchedValues.savedQueryId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error update types
|
||||
if (ref && ref.current) {
|
||||
// @ts-expect-error update types
|
||||
ref.current.validation[item.id] = async () => {
|
||||
await handleSubmit(onSubmit)();
|
||||
|
||||
return isEmpty(errors);
|
||||
};
|
||||
}
|
||||
}, [errors, handleSubmit, isValid, item.id, onSubmit, ref, watchedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
register('savedQueryId');
|
||||
register('id');
|
||||
}, [register]);
|
||||
|
||||
const permissions = useKibana().services.application.capabilities.osquery;
|
||||
|
||||
useEffectOnce(() => {
|
||||
if (defaultParams && defaultParams.id) {
|
||||
const { packId, ecs_mapping: ecsMapping, ...restParams } = defaultParams;
|
||||
map(restParams, (value, key: keyof OsqueryResponseActionsParamsFormFields) => {
|
||||
if (!isEmpty(value)) {
|
||||
setValue(key, value);
|
||||
}
|
||||
});
|
||||
if (ecsMapping) {
|
||||
const converted = convertECSMappingToFormValue(ecsMapping);
|
||||
setValue('ecs_mapping', converted);
|
||||
}
|
||||
|
||||
if (!isEmpty(packId)) {
|
||||
setValue('packId', [packId]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const resetFormFields = useCallback(() => {
|
||||
setValue('packId', []);
|
||||
setValue('savedQueryId', '');
|
||||
setValue('query', '');
|
||||
setValue('ecs_mapping', [defaultEcsFormData]);
|
||||
clearErrors();
|
||||
}, [clearErrors, setValue]);
|
||||
|
||||
const canRunPacks = useMemo(
|
||||
() =>
|
||||
!!((permissions.runSavedQueries || permissions.writeLiveQueries) && permissions.readPacks),
|
||||
[permissions]
|
||||
);
|
||||
const canRunSingleQuery = useMemo(
|
||||
() =>
|
||||
!!(
|
||||
permissions.writeLiveQueries ||
|
||||
(permissions.runSavedQueries && permissions.readSavedQueries)
|
||||
),
|
||||
[permissions]
|
||||
);
|
||||
|
||||
const queryDetails = useMemo(
|
||||
() => ({
|
||||
queries: watchedValues.queries,
|
||||
action_id: watchedValues.id,
|
||||
agents: [],
|
||||
}),
|
||||
[watchedValues.id, watchedValues.queries]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...hooksForm}>
|
||||
<QueryPackSelectable
|
||||
queryType={queryType}
|
||||
setQueryType={setQueryType}
|
||||
canRunPacks={canRunPacks}
|
||||
canRunSingleQuery={canRunSingleQuery}
|
||||
resetFormFields={resetFormFields}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
{queryType === 'query' && <LiveQueryQueryField />}
|
||||
{queryType === 'pack' && (
|
||||
<PackFieldWrapper
|
||||
liveQueryDetails={watchedValues.queries && !packData ? queryDetails : undefined}
|
||||
/>
|
||||
)}
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const OsqueryResponseActionParamsForm = React.memo(OsqueryResponseActionParamsFormComponent);
|
||||
|
||||
// Export as default in order to support lazy loading
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { OsqueryResponseActionParamsForm as default };
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 { EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { find } from 'lodash';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import type { EcsMappingFormField } from '../../packs/queries/ecs_mapping_editor_field';
|
||||
import { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table';
|
||||
import { usePacks } from '../../packs/use_packs';
|
||||
import { PacksComboBoxField } from '../../live_queries/form/packs_combobox_field';
|
||||
|
||||
interface PackFieldWrapperProps {
|
||||
liveQueryDetails?: {
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
query: string;
|
||||
ecs_mapping?: EcsMappingFormField[];
|
||||
}>;
|
||||
action_id?: string;
|
||||
agents?: string[];
|
||||
};
|
||||
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
|
||||
submitButtonContent?: React.ReactNode;
|
||||
addToCase?: ({ actionId }: { actionId?: string }) => ReactElement;
|
||||
showResultsHeader?: boolean;
|
||||
}
|
||||
|
||||
export const PackFieldWrapper = ({
|
||||
liveQueryDetails,
|
||||
addToTimeline,
|
||||
submitButtonContent,
|
||||
addToCase,
|
||||
showResultsHeader,
|
||||
}: PackFieldWrapperProps) => {
|
||||
const { data: packsData } = usePacks({});
|
||||
const { packId } = useWatch() as unknown as { packId: string[] };
|
||||
|
||||
const selectedPackData = useMemo(
|
||||
() => (packId?.length ? find(packsData?.data, { id: packId[0] }) : null),
|
||||
[packId, packsData]
|
||||
);
|
||||
|
||||
const actionId = useMemo(() => liveQueryDetails?.action_id, [liveQueryDetails?.action_id]);
|
||||
const agentIds = useMemo(() => liveQueryDetails?.agents, [liveQueryDetails?.agents]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
<PacksComboBoxField
|
||||
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
|
||||
fieldProps={{ packsData: packsData?.data }}
|
||||
queryType={'pack'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{submitButtonContent}
|
||||
<EuiSpacer />
|
||||
|
||||
{liveQueryDetails?.queries?.length || selectedPackData?.attributes?.queries?.length ? (
|
||||
<>
|
||||
<EuiFlexItem>
|
||||
<PackQueriesStatusTable
|
||||
actionId={actionId}
|
||||
agentIds={agentIds}
|
||||
data={liveQueryDetails?.queries ?? selectedPackData?.attributes?.queries}
|
||||
addToTimeline={addToTimeline}
|
||||
addToCase={addToCase}
|
||||
showResultsHeader={showResultsHeader}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 { EuiErrorBoundary, EuiSpacer } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
|
||||
import { useAllLiveQueries } from '../../actions/use_all_live_queries';
|
||||
import { KibanaContextProvider } from '../../common/lib/kibana';
|
||||
import { Direction } from '../../../common/search_strategy';
|
||||
|
||||
import { queryClient } from '../../query_client';
|
||||
import { KibanaThemeProvider } from '../../shared_imports';
|
||||
import type { StartPlugins } from '../../types';
|
||||
import type { OsqueryActionResultsProps } from './types';
|
||||
import { OsqueryResult } from './osquery_result';
|
||||
|
||||
const OsqueryActionResultsComponent: React.FC<OsqueryActionResultsProps> = ({
|
||||
agentIds,
|
||||
ruleName,
|
||||
alertId,
|
||||
addToTimeline,
|
||||
}) => {
|
||||
const { data: actionsData } = useAllLiveQueries({
|
||||
filterQuery: { term: { alert_ids: alertId } },
|
||||
activePage: 0,
|
||||
limit: 100,
|
||||
direction: Direction.desc,
|
||||
sortField: '@timestamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-test-subj={'osquery-results'}>
|
||||
{actionsData?.data.items.map((item, index) => {
|
||||
const actionId = item.fields?.action_id?.[0];
|
||||
const queryId = item.fields?.['queries.action_id']?.[0];
|
||||
// const query = item.fields?.['queries.query']?.[0];
|
||||
const startDate = item.fields?.['@timestamp'][0];
|
||||
|
||||
return (
|
||||
<OsqueryResult
|
||||
key={actionId + index}
|
||||
actionId={actionId}
|
||||
queryId={queryId}
|
||||
startDate={startDate}
|
||||
ruleName={ruleName}
|
||||
addToTimeline={addToTimeline}
|
||||
agentIds={agentIds}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OsqueryActionResults = React.memo(OsqueryActionResultsComponent);
|
||||
|
||||
type OsqueryActionResultsWrapperProps = {
|
||||
services: CoreStart & StartPlugins;
|
||||
} & OsqueryActionResultsProps;
|
||||
|
||||
const OsqueryActionResultsWrapperComponent: React.FC<OsqueryActionResultsWrapperProps> = ({
|
||||
services,
|
||||
...restProps
|
||||
}) => (
|
||||
<KibanaThemeProvider theme$={services.theme.theme$}>
|
||||
<KibanaContextProvider services={services}>
|
||||
<EuiErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OsqueryActionResults {...restProps} />
|
||||
</QueryClientProvider>
|
||||
</EuiErrorBoundary>
|
||||
</KibanaContextProvider>
|
||||
</KibanaThemeProvider>
|
||||
);
|
||||
|
||||
const OsqueryActionResultsWrapper = React.memo(OsqueryActionResultsWrapperComponent);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { OsqueryActionResultsWrapper as default };
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* 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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { queryClient } from '../../query_client';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import * as useLiveQueryDetails from '../../actions/use_live_query_details';
|
||||
import { PERMISSION_DENIED } from '../osquery_action/translations';
|
||||
import { OsqueryResult } from './osquery_result';
|
||||
import {
|
||||
defaultLiveQueryDetails,
|
||||
DETAILS_ID,
|
||||
DETAILS_QUERY,
|
||||
DETAILS_TIMESTAMP,
|
||||
mockCasesContext,
|
||||
} from './test_utils';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
|
||||
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
|
||||
|
||||
const defaultPermissions = {
|
||||
osquery: {
|
||||
runSavedQueries: true,
|
||||
readSavedQueries: true,
|
||||
},
|
||||
discover: {
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
actionId: 'test-action-id',
|
||||
startDate: DETAILS_TIMESTAMP,
|
||||
queryId: '',
|
||||
};
|
||||
const mockKibana = (permissionType: unknown = defaultPermissions) => {
|
||||
useKibanaMock.mockReturnValue({
|
||||
services: {
|
||||
application: {
|
||||
capabilities: permissionType,
|
||||
},
|
||||
cases: {
|
||||
helpers: {
|
||||
canUseCases: jest.fn(),
|
||||
},
|
||||
ui: {
|
||||
getCasesContext: jest.fn().mockImplementation(() => mockCasesContext),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
dataViews: {
|
||||
getCanSaveSync: jest.fn(),
|
||||
hasData: {
|
||||
hasESData: jest.fn(),
|
||||
hasUserDataView: jest.fn(),
|
||||
hasDataView: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
toasts: jest.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useKibana>);
|
||||
};
|
||||
|
||||
const renderWithContext = (Element: React.ReactElement) =>
|
||||
render(
|
||||
<IntlProvider locale={'en'}>
|
||||
<QueryClientProvider client={queryClient}>{Element}</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('Osquery Results', () => {
|
||||
beforeAll(() => {
|
||||
mockKibana();
|
||||
jest
|
||||
.spyOn(useLiveQueryDetails, 'useLiveQueryDetails')
|
||||
.mockImplementation(() => defaultLiveQueryDetails);
|
||||
});
|
||||
|
||||
it('return results table', async () => {
|
||||
const { getByText, queryByText, getByTestId } = renderWithContext(
|
||||
<OsqueryResult {...defaultProps} />
|
||||
);
|
||||
expect(queryByText(PERMISSION_DENIED)).not.toBeInTheDocument();
|
||||
expect(getByTestId('osquery-results-comment'));
|
||||
expect(getByText(DETAILS_QUERY)).toBeInTheDocument();
|
||||
expect(getByText(DETAILS_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { EuiComment, EuiSpacer } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { FormattedRelative } from '@kbn/i18n-react';
|
||||
|
||||
import type { OsqueryActionResultsProps } from './types';
|
||||
import { useLiveQueryDetails } from '../../actions/use_live_query_details';
|
||||
import { ATTACHED_QUERY } from '../../agents/translations';
|
||||
import { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table';
|
||||
|
||||
interface OsqueryResultProps extends Omit<OsqueryActionResultsProps, 'alertId'> {
|
||||
actionId: string;
|
||||
queryId: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export const OsqueryResult = ({
|
||||
actionId,
|
||||
queryId,
|
||||
ruleName,
|
||||
addToTimeline,
|
||||
agentIds,
|
||||
startDate,
|
||||
}: OsqueryResultProps) => {
|
||||
const { data } = useLiveQueryDetails({
|
||||
actionId,
|
||||
// isLive,
|
||||
// ...(queryId ? { queryIds: [queryId] } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiComment
|
||||
username={ruleName && ruleName[0]}
|
||||
timestamp={<FormattedRelative value={startDate} />}
|
||||
event={ATTACHED_QUERY}
|
||||
data-test-subj={'osquery-results-comment'}
|
||||
>
|
||||
<PackQueriesStatusTable
|
||||
actionId={actionId}
|
||||
// queryId={queryId}
|
||||
data={data?.queries}
|
||||
startDate={data?.['@timestamp']}
|
||||
expirationDate={data?.expiration}
|
||||
agentIds={agentIds}
|
||||
addToTimeline={addToTimeline}
|
||||
/>
|
||||
</EuiComment>
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* 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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { OsqueryActionResults } from '.';
|
||||
import { queryClient } from '../../query_client';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import * as useAllLiveQueries from '../../actions/use_all_live_queries';
|
||||
import * as useLiveQueryDetails from '../../actions/use_live_query_details';
|
||||
import { PERMISSION_DENIED } from '../osquery_action/translations';
|
||||
import * as privileges from '../../action_results/use_action_privileges';
|
||||
import { defaultLiveQueryDetails, DETAILS_QUERY, mockCasesContext } from './test_utils';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
|
||||
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
|
||||
|
||||
const enablePrivileges = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
jest.spyOn(privileges, 'useActionResultsPrivileges').mockImplementation(() => ({
|
||||
data: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
agentIds: ['agent1'],
|
||||
ruleName: ['Test-rule'],
|
||||
ruleActions: [{ action_type_id: 'action1' }, { action_type_id: 'action2' }],
|
||||
alertId: 'test-alert-id',
|
||||
};
|
||||
|
||||
const defaultPermissions = {
|
||||
osquery: {
|
||||
runSavedQueries: false,
|
||||
readSavedQueries: false,
|
||||
},
|
||||
discover: {
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockKibana = (permissionType: unknown = defaultPermissions) => {
|
||||
useKibanaMock.mockReturnValue({
|
||||
services: {
|
||||
application: {
|
||||
capabilities: permissionType,
|
||||
},
|
||||
cases: {
|
||||
helpers: {
|
||||
canUseCases: jest.fn(),
|
||||
},
|
||||
ui: {
|
||||
getCasesContext: jest.fn().mockImplementation(() => mockCasesContext),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
dataViews: {
|
||||
getCanSaveSync: jest.fn(),
|
||||
hasData: {
|
||||
hasESData: jest.fn(),
|
||||
hasUserDataView: jest.fn(),
|
||||
hasDataView: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
toasts: jest.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useKibana>);
|
||||
};
|
||||
|
||||
const renderWithContext = (Element: React.ReactElement) =>
|
||||
render(
|
||||
<IntlProvider locale={'en'}>
|
||||
<QueryClientProvider client={queryClient}>{Element}</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('Osquery Results', () => {
|
||||
beforeAll(() => {
|
||||
mockKibana();
|
||||
// @ts-expect-error update types
|
||||
jest.spyOn(useAllLiveQueries, 'useAllLiveQueries').mockImplementation(() => ({
|
||||
data: {
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
fields: {
|
||||
action_id: ['sfdsfds'],
|
||||
'queries.action_id': ['dsadas'],
|
||||
'queries.query': [DETAILS_QUERY],
|
||||
'@timestamp': ['2022-09-08T18:16:30.256Z'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest
|
||||
.spyOn(useLiveQueryDetails, 'useLiveQueryDetails')
|
||||
.mockImplementation(() => defaultLiveQueryDetails);
|
||||
});
|
||||
|
||||
it('should validate permissions', async () => {
|
||||
const { queryByText } = renderWithContext(<OsqueryActionResults {...defaultProps} />);
|
||||
expect(queryByText(PERMISSION_DENIED)).toBeInTheDocument();
|
||||
});
|
||||
it('return results table', async () => {
|
||||
enablePrivileges();
|
||||
const { getByText, queryByText, getByTestId } = renderWithContext(
|
||||
<OsqueryActionResults {...defaultProps} />
|
||||
);
|
||||
expect(queryByText(PERMISSION_DENIED)).not.toBeInTheDocument();
|
||||
expect(getByTestId('osquery-results-comment'));
|
||||
expect(getByText('Test-rule')).toBeInTheDocument();
|
||||
expect(getByText('attached query')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -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 React from 'react';
|
||||
|
||||
export const DETAILS_QUERY = 'select * from uptime';
|
||||
export const DETAILS_ID = 'test-id';
|
||||
export const DETAILS_ACTION_ID = 'test-action-id';
|
||||
export const DETAILS_DOCS_COUNT = 20;
|
||||
export const DETAILS_TIMESTAMP = '2022-09-08T14:58:43.580Z';
|
||||
|
||||
export const defaultLiveQueryDetails = {
|
||||
data: {
|
||||
'@timestamp': DETAILS_TIMESTAMP,
|
||||
action_id: 'a77643d3-0876-4179-b077-24ed9f8c58f5',
|
||||
agents: ['e157a15c-6013-423b-a139-4eb41baf5be9'],
|
||||
expiration: '2022-09-08T15:03:43.580Z',
|
||||
queries: [
|
||||
{
|
||||
action_id: DETAILS_ACTION_ID,
|
||||
agents: ['e157a15c-6013-423b-a139-4eb41baf5be9'],
|
||||
docs: DETAILS_DOCS_COUNT,
|
||||
failed: 0,
|
||||
id: DETAILS_ID,
|
||||
pending: 0,
|
||||
query: DETAILS_QUERY,
|
||||
responded: 1,
|
||||
saved_query_id: 'osquery_manager-cebd7b00-b4b4-11ec-8f39-bf9c07530bbb',
|
||||
status: 'completed',
|
||||
successful: 1,
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
},
|
||||
} as never;
|
||||
|
||||
export const mockCasesContext: React.FC = (props) => <>{props?.children ?? null}</>;
|
|
@ -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 type React from 'react';
|
||||
|
||||
export interface OsqueryActionResultsProps {
|
||||
agentIds?: string[];
|
||||
ruleName?: string[];
|
||||
alertId: string;
|
||||
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
|
||||
}
|
|
@ -8,9 +8,9 @@
|
|||
import { useMemo } from 'react';
|
||||
import { find, isString } from 'lodash';
|
||||
import type { AgentStatus, PackagePolicy } from '@kbn/fleet-plugin/common';
|
||||
import { useAgentDetails } from '../../agents/use_agent_details';
|
||||
import { useAgentPolicy } from '../../agent_policies';
|
||||
import { OSQUERY_INTEGRATION_NAME } from '../../../common';
|
||||
import { useAgentPolicy } from '../agent_policies';
|
||||
import { OSQUERY_INTEGRATION_NAME } from '../../common';
|
||||
import { useAgentDetails } from '../agents/use_agent_details';
|
||||
|
||||
interface IIsOsqueryAvailable {
|
||||
osqueryAvailable: boolean;
|
|
@ -18,15 +18,23 @@ import type {
|
|||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import type { CasesUiStart, CasesUiSetup } from '@kbn/cases-plugin/public';
|
||||
import type { TimelinesUIStart } from '@kbn/timelines-plugin/public';
|
||||
import type { getLazyLiveQueryField, getLazyOsqueryAction } from './shared_components';
|
||||
import type {
|
||||
getLazyOsqueryResults,
|
||||
getLazyLiveQueryField,
|
||||
getLazyOsqueryAction,
|
||||
getLazyOsqueryResponseActionTypeForm,
|
||||
} from './shared_components';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface OsqueryPluginSetup {}
|
||||
|
||||
export interface OsqueryPluginStart {
|
||||
OsqueryAction?: ReturnType<typeof getLazyOsqueryAction>;
|
||||
OsqueryResults: ReturnType<typeof getLazyOsqueryResults>;
|
||||
LiveQueryField?: ReturnType<typeof getLazyLiveQueryField>;
|
||||
isOsqueryAvailable: (props: { agentId: string }) => boolean;
|
||||
fetchInstallationStatus: () => { loading: boolean; disabled: boolean; permissionDenied: boolean };
|
||||
OsqueryResponseActionTypeForm: ReturnType<typeof getLazyOsqueryResponseActionTypeForm>;
|
||||
}
|
||||
|
||||
export interface AppPluginStartDependencies {
|
||||
|
|
|
@ -15,14 +15,16 @@
|
|||
"kibana": [
|
||||
{
|
||||
"feature": {
|
||||
"discover": ["read"],
|
||||
"discover": ["all"],
|
||||
"infrastructure": ["read"],
|
||||
"observabilityCases": ["all"],
|
||||
"securitySolutionCases": ["all"],
|
||||
"ml": ["all"],
|
||||
"siem": ["all"],
|
||||
"savedObjectsManagement": ["all"],
|
||||
"osquery": ["all"],
|
||||
"visualize": ["read"]
|
||||
"visualize": ["read"],
|
||||
"actions": ["all"]
|
||||
},
|
||||
"spaces": ["*"]
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import type { PluginInitializerContext } from '@kbn/core/server';
|
||||
|
||||
import type { ConfigType } from './config';
|
||||
import type { ConfigType } from '../common/config';
|
||||
|
||||
export const createConfig = (context: PluginInitializerContext): Readonly<ConfigType> =>
|
||||
context.config.get<ConfigType>();
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* 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 uuid from 'uuid';
|
||||
import moment from 'moment';
|
||||
import { flatten, isEmpty, map, omit, pick, pickBy, some } from 'lodash';
|
||||
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { getInternalSavedObjectsClient } from '../../routes/utils';
|
||||
import { parseAgentSelection } from '../../lib/parse_agent_groups';
|
||||
import { packSavedObjectType } from '../../../common/types';
|
||||
import type { OsqueryAppContext } from '../../lib/osquery_app_context_services';
|
||||
import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
|
||||
import { convertSOQueriesToPack } from '../../routes/pack/utils';
|
||||
import { isSavedQueryPrebuilt } from '../../routes/saved_query/utils';
|
||||
import { ACTIONS_INDEX } from '../../../common/constants';
|
||||
import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants';
|
||||
import type { PackSavedObjectAttributes } from '../../common/types';
|
||||
|
||||
interface Metadata {
|
||||
currentUser: string | undefined;
|
||||
}
|
||||
|
||||
export const createActionHandler = async (
|
||||
osqueryContext: OsqueryAppContext,
|
||||
params: CreateLiveQueryRequestBodySchema,
|
||||
metadata?: Metadata
|
||||
) => {
|
||||
const [coreStartServices] = await osqueryContext.getStartServices();
|
||||
const esClientInternal = coreStartServices.elasticsearch.client.asInternalUser;
|
||||
const soClient = coreStartServices.savedObjects.createInternalRepository();
|
||||
const internalSavedObjectsClient = await getInternalSavedObjectsClient(
|
||||
osqueryContext.getStartServices
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { agent_all, agent_ids, agent_platforms, agent_policy_ids } = params;
|
||||
const selectedAgents = await parseAgentSelection(internalSavedObjectsClient, osqueryContext, {
|
||||
agents: agent_ids,
|
||||
allAgentsSelected: !!agent_all,
|
||||
platformsSelected: agent_platforms,
|
||||
policiesSelected: agent_policy_ids,
|
||||
});
|
||||
if (!selectedAgents.length) {
|
||||
throw new Error('No agents found for selection');
|
||||
}
|
||||
|
||||
let packSO;
|
||||
|
||||
if (params.pack_id) {
|
||||
packSO = await soClient.get<PackSavedObjectAttributes>(packSavedObjectType, params.pack_id);
|
||||
}
|
||||
|
||||
const osqueryAction = {
|
||||
action_id: uuid.v4(),
|
||||
'@timestamp': moment().toISOString(),
|
||||
expiration: moment().add(5, 'minutes').toISOString(),
|
||||
type: 'INPUT_ACTION',
|
||||
input_type: 'osquery',
|
||||
alert_ids: params.alert_ids,
|
||||
event_ids: params.event_ids,
|
||||
case_ids: params.case_ids,
|
||||
agent_ids: params.agent_ids,
|
||||
agent_all: params.agent_all,
|
||||
agent_platforms: params.agent_platforms,
|
||||
agent_policy_ids: params.agent_policy_ids,
|
||||
agents: selectedAgents,
|
||||
user_id: metadata?.currentUser,
|
||||
metadata: params.metadata,
|
||||
pack_id: params.pack_id,
|
||||
pack_name: packSO?.attributes?.name,
|
||||
pack_prebuilt: params.pack_id
|
||||
? !!some(packSO?.references, ['type', 'osquery-pack-asset'])
|
||||
: undefined,
|
||||
queries: packSO
|
||||
? map(convertSOQueriesToPack(packSO.attributes.queries), (packQuery, packQueryId) =>
|
||||
pickBy(
|
||||
{
|
||||
action_id: uuid.v4(),
|
||||
id: packQueryId,
|
||||
query: packQuery.query,
|
||||
ecs_mapping: packQuery.ecs_mapping,
|
||||
version: packQuery.version,
|
||||
platform: packQuery.platform,
|
||||
agents: selectedAgents,
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
)
|
||||
)
|
||||
: params.queries?.length
|
||||
? map(params.queries, (query) =>
|
||||
pickBy(
|
||||
{
|
||||
// @ts-expect-error where does type 'number' comes from?
|
||||
...query,
|
||||
action_id: uuid.v4(),
|
||||
agents: selectedAgents,
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
)
|
||||
)
|
||||
: [
|
||||
pickBy(
|
||||
{
|
||||
action_id: uuid.v4(),
|
||||
id: uuid.v4(),
|
||||
query: params.query,
|
||||
saved_query_id: params.saved_query_id,
|
||||
saved_query_prebuilt: params.saved_query_id
|
||||
? await isSavedQueryPrebuilt(
|
||||
osqueryContext.service.getPackageService()?.asInternalUser,
|
||||
params.saved_query_id
|
||||
)
|
||||
: undefined,
|
||||
ecs_mapping: params.ecs_mapping,
|
||||
agents: selectedAgents,
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const fleetActions = map(osqueryAction.queries, (query) => ({
|
||||
action_id: query.action_id,
|
||||
'@timestamp': moment().toISOString(),
|
||||
expiration: moment().add(5, 'minutes').toISOString(),
|
||||
type: 'INPUT_ACTION',
|
||||
input_type: 'osquery',
|
||||
agents: query.agents,
|
||||
user_id: metadata?.currentUser,
|
||||
data: pick(query, ['id', 'query', 'ecs_mapping', 'version', 'platform']),
|
||||
}));
|
||||
|
||||
await esClientInternal.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: flatten(
|
||||
fleetActions.map((action) => [{ index: { _index: AGENT_ACTIONS_INDEX } }, action])
|
||||
),
|
||||
});
|
||||
|
||||
const actionsComponentTemplateExists = await esClientInternal.indices.exists({
|
||||
index: `${ACTIONS_INDEX}*`,
|
||||
});
|
||||
|
||||
if (actionsComponentTemplateExists) {
|
||||
await esClientInternal.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: [{ index: { _index: `${ACTIONS_INDEX}-default` } }, osqueryAction],
|
||||
});
|
||||
}
|
||||
|
||||
osqueryContext.telemetryEventsSender.reportEvent(TELEMETRY_EBT_LIVE_QUERY_EVENT, {
|
||||
...omit(osqueryAction, ['type', 'input_type', 'user_id']),
|
||||
agents: osqueryAction.agents.length,
|
||||
});
|
||||
|
||||
return {
|
||||
response: osqueryAction,
|
||||
};
|
||||
};
|
8
x-pack/plugins/osquery/server/handlers/action/index.ts
Normal file
8
x-pack/plugins/osquery/server/handlers/action/index.ts
Normal 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 './create_action_handler';
|
8
x-pack/plugins/osquery/server/handlers/index.ts
Normal file
8
x-pack/plugins/osquery/server/handlers/index.ts
Normal 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 './action';
|
|
@ -7,15 +7,13 @@
|
|||
|
||||
import type { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
|
||||
import { OsqueryPlugin } from './plugin';
|
||||
import type { ConfigType } from './config';
|
||||
import { ConfigSchema } from './config';
|
||||
import type { ConfigType } from '../common/config';
|
||||
import { ConfigSchema } from '../common/config';
|
||||
|
||||
export const config: PluginConfigDescriptor<ConfigType> = {
|
||||
schema: ConfigSchema,
|
||||
exposeToBrowser: {
|
||||
actionEnabled: true,
|
||||
savedQueries: true,
|
||||
packs: true,
|
||||
},
|
||||
};
|
||||
export function plugin(initializerContext: PluginInitializerContext) {
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {
|
|||
AgentPolicyServiceInterface,
|
||||
PackagePolicyClient,
|
||||
} from '@kbn/fleet-plugin/server';
|
||||
import type { ConfigType } from '../config';
|
||||
import type { ConfigType } from '../../common/config';
|
||||
import type { TelemetryEventsSender } from './telemetry/sender';
|
||||
|
||||
export type OsqueryAppContextServiceStartContract = Partial<
|
||||
|
|
|
@ -17,6 +17,7 @@ import type { PackagePolicy } from '@kbn/fleet-plugin/common';
|
|||
import type { DataRequestHandlerContext } from '@kbn/data-plugin/server';
|
||||
import type { DataViewsService } from '@kbn/data-views-plugin/common';
|
||||
|
||||
import type { CreateLiveQueryRequestBodySchema } from '../common/schemas/routes/live_query';
|
||||
import { createConfig } from './create_config';
|
||||
import type { OsqueryPluginSetup, OsqueryPluginStart, SetupPlugins, StartPlugins } from './types';
|
||||
import { defineRoutes } from './routes';
|
||||
|
@ -25,7 +26,7 @@ import { initSavedObjects } from './saved_objects';
|
|||
import { initUsageCollectors } from './usage';
|
||||
import type { OsqueryAppContext } from './lib/osquery_app_context_services';
|
||||
import { OsqueryAppContextService } from './lib/osquery_app_context_services';
|
||||
import type { ConfigType } from './config';
|
||||
import type { ConfigType } from '../common/config';
|
||||
import { OSQUERY_INTEGRATION_NAME } from '../common';
|
||||
import { getPackagePolicyDeleteCallback } from './lib/fleet_integration';
|
||||
import { TelemetryEventsSender } from './lib/telemetry/sender';
|
||||
|
@ -33,6 +34,7 @@ import { TelemetryReceiver } from './lib/telemetry/receiver';
|
|||
import { initializeTransformsIndices } from './create_indices/create_transforms_indices';
|
||||
import { initializeTransforms } from './create_transforms/create_transforms';
|
||||
import { createDataViews } from './create_data_views';
|
||||
import { createActionHandler } from './handlers/action';
|
||||
|
||||
import { registerFeatures } from './utils/register_features';
|
||||
|
||||
|
@ -86,7 +88,10 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
|
|||
|
||||
this.telemetryEventsSender.setup(this.telemetryReceiver, plugins.taskManager, core.analytics);
|
||||
|
||||
return {};
|
||||
return {
|
||||
osqueryCreateAction: (params: CreateLiveQueryRequestBodySchema) =>
|
||||
createActionHandler(osqueryContext, params),
|
||||
};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, plugins: StartPlugins) {
|
||||
|
|
|
@ -5,26 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { some, flatten, map, pick, pickBy, isEmpty, omit } from 'lodash';
|
||||
import uuid from 'uuid';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import type { IRouter } from '@kbn/core/server';
|
||||
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import type { OsqueryAppContext } from '../../lib/osquery_app_context_services';
|
||||
|
||||
import { parseAgentSelection } from '../../lib/parse_agent_groups';
|
||||
import { buildRouteValidation } from '../../utils/build_validation/route_validation';
|
||||
import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
|
||||
import { createLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
|
||||
|
||||
import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types';
|
||||
import { ACTIONS_INDEX } from '../../../common/constants';
|
||||
import { convertSOQueriesToPack } from '../pack/utils';
|
||||
import type { PackSavedObjectAttributes } from '../../common/types';
|
||||
import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants';
|
||||
import { isSavedQueryPrebuilt } from '../saved_query/utils';
|
||||
import { getInternalSavedObjectsClient } from '../utils';
|
||||
import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
|
||||
import { buildRouteValidation } from '../../utils/build_validation/route_validation';
|
||||
import type { OsqueryAppContext } from '../../lib/osquery_app_context_services';
|
||||
import { createActionHandler } from '../../handlers';
|
||||
|
||||
export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => {
|
||||
router.post(
|
||||
|
@ -38,14 +25,7 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp
|
|||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asInternalUser;
|
||||
const soClient = coreContext.savedObjects.client;
|
||||
const internalSavedObjectsClient = await getInternalSavedObjectsClient(
|
||||
osqueryContext.getStartServices
|
||||
);
|
||||
const [coreStartServices] = await osqueryContext.getStartServices();
|
||||
let savedQueryId = request.body.saved_query_id;
|
||||
|
||||
const {
|
||||
osquery: { writeLiveQueries, runSavedQueries },
|
||||
|
@ -60,138 +40,21 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp
|
|||
return response.forbidden();
|
||||
}
|
||||
|
||||
if (request.body.saved_query_id && runSavedQueries) {
|
||||
const savedQueries = await soClient.find({
|
||||
type: savedQuerySavedObjectType,
|
||||
});
|
||||
const actualSavedQuery = savedQueries.saved_objects.find(
|
||||
(savedQuery) => savedQuery.id === request.body.saved_query_id
|
||||
);
|
||||
|
||||
if (actualSavedQuery) {
|
||||
savedQueryId = actualSavedQuery.id;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { agent_all, agent_ids, agent_platforms, agent_policy_ids } = request.body;
|
||||
const selectedAgents = await parseAgentSelection(internalSavedObjectsClient, osqueryContext, {
|
||||
agents: agent_ids,
|
||||
allAgentsSelected: !!agent_all,
|
||||
platformsSelected: agent_platforms,
|
||||
policiesSelected: agent_policy_ids,
|
||||
});
|
||||
if (!selectedAgents.length) {
|
||||
return response.badRequest({ body: new Error('No agents found for selection') });
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username;
|
||||
|
||||
let packSO;
|
||||
|
||||
if (request.body.pack_id) {
|
||||
packSO = await soClient.get<PackSavedObjectAttributes>(
|
||||
packSavedObjectType,
|
||||
request.body.pack_id
|
||||
);
|
||||
}
|
||||
|
||||
const osqueryAction = {
|
||||
action_id: uuid.v4(),
|
||||
'@timestamp': moment().toISOString(),
|
||||
expiration: moment().add(5, 'minutes').toISOString(),
|
||||
type: 'INPUT_ACTION',
|
||||
input_type: 'osquery',
|
||||
alert_ids: request.body.alert_ids,
|
||||
event_ids: request.body.event_ids,
|
||||
case_ids: request.body.case_ids,
|
||||
agent_ids: request.body.agent_ids,
|
||||
agent_all: request.body.agent_all,
|
||||
agent_platforms: request.body.agent_platforms,
|
||||
agent_policy_ids: request.body.agent_policy_ids,
|
||||
agents: selectedAgents,
|
||||
user_id: currentUser,
|
||||
metadata: request.body.metadata,
|
||||
pack_id: request.body.pack_id,
|
||||
pack_name: packSO?.attributes?.name,
|
||||
pack_prebuilt: request.body.pack_id
|
||||
? !!some(packSO?.references, ['type', 'osquery-pack-asset'])
|
||||
: undefined,
|
||||
queries: packSO
|
||||
? map(convertSOQueriesToPack(packSO.attributes.queries), (packQuery, packQueryId) =>
|
||||
pickBy(
|
||||
{
|
||||
action_id: uuid.v4(),
|
||||
id: packQueryId,
|
||||
query: packQuery.query,
|
||||
ecs_mapping: packQuery.ecs_mapping,
|
||||
version: packQuery.version,
|
||||
platform: packQuery.platform,
|
||||
agents: selectedAgents,
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
)
|
||||
)
|
||||
: [
|
||||
pickBy(
|
||||
{
|
||||
action_id: uuid.v4(),
|
||||
id: uuid.v4(),
|
||||
query: request.body.query,
|
||||
saved_query_id: savedQueryId,
|
||||
saved_query_prebuilt: savedQueryId
|
||||
? await isSavedQueryPrebuilt(
|
||||
osqueryContext.service.getPackageService()?.asInternalUser,
|
||||
savedQueryId
|
||||
)
|
||||
: undefined,
|
||||
ecs_mapping: request.body.ecs_mapping,
|
||||
agents: selectedAgents,
|
||||
},
|
||||
(value) => !isEmpty(value)
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const fleetActions = map(osqueryAction.queries, (query) => ({
|
||||
action_id: query.action_id,
|
||||
'@timestamp': moment().toISOString(),
|
||||
expiration: moment().add(5, 'minutes').toISOString(),
|
||||
type: 'INPUT_ACTION',
|
||||
input_type: 'osquery',
|
||||
agents: query.agents,
|
||||
user_id: currentUser,
|
||||
data: pick(query, ['id', 'query', 'ecs_mapping', 'version', 'platform']),
|
||||
}));
|
||||
|
||||
await esClient.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: flatten(
|
||||
fleetActions.map((action) => [{ index: { _index: AGENT_ACTIONS_INDEX } }, action])
|
||||
),
|
||||
});
|
||||
|
||||
const actionsComponentTemplateExists = await esClient.indices.exists({
|
||||
index: `${ACTIONS_INDEX}*`,
|
||||
});
|
||||
|
||||
if (actionsComponentTemplateExists) {
|
||||
await esClient.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: [{ index: { _index: `${ACTIONS_INDEX}-default` } }, osqueryAction],
|
||||
});
|
||||
}
|
||||
|
||||
osqueryContext.telemetryEventsSender.reportEvent(TELEMETRY_EBT_LIVE_QUERY_EVENT, {
|
||||
...omit(osqueryAction, ['type', 'input_type', 'user_id']),
|
||||
agents: osqueryAction.agents.length,
|
||||
});
|
||||
const { response: osqueryAction } = await createActionHandler(
|
||||
osqueryContext,
|
||||
request.body,
|
||||
{ currentUser }
|
||||
);
|
||||
|
||||
return response.ok({
|
||||
body: { data: osqueryAction },
|
||||
});
|
||||
} catch (error) {
|
||||
// TODO validate for 400 (when agents are not found for selection)
|
||||
// return response.badRequest({ body: new Error('No agents found for selection') });
|
||||
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: new Error(`Error occurred while processing ${error}`),
|
||||
|
|
|
@ -11,25 +11,24 @@ import type { ISearchRequestParams } from '@kbn/data-plugin/common';
|
|||
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { ACTIONS_INDEX } from '../../../../../../common/constants';
|
||||
import type { AgentsRequestOptions } from '../../../../../../common/search_strategy';
|
||||
// import { createQueryFilterClauses } from '../../../../../../common/utils/build_query';
|
||||
import { createQueryFilterClauses } from '../../../../../../common/utils/build_query';
|
||||
|
||||
export const buildActionsQuery = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
filterQuery,
|
||||
sort,
|
||||
pagination: { cursorStart, querySize },
|
||||
componentTemplateExists,
|
||||
}: AgentsRequestOptions): ISearchRequestParams => {
|
||||
// const filter = [...createQueryFilterClauses(filterQuery)];
|
||||
const filter = [...createQueryFilterClauses(filterQuery)];
|
||||
|
||||
const dslQuery = {
|
||||
allow_no_indices: true,
|
||||
index: componentTemplateExists ? `${ACTIONS_INDEX}*` : AGENT_ACTIONS_INDEX,
|
||||
ignore_unavailable: true,
|
||||
body: {
|
||||
// query: { bool: { filter } },
|
||||
query: {
|
||||
bool: {
|
||||
filter,
|
||||
must: [
|
||||
{
|
||||
term: {
|
||||
|
|
|
@ -20,9 +20,12 @@ import type {
|
|||
TaskManagerStartContract as TaskManagerPluginStart,
|
||||
} from '@kbn/task-manager-plugin/server';
|
||||
import type { PluginStart as DataViewsPluginStart } from '@kbn/data-views-plugin/server';
|
||||
import type { CreateLiveQueryRequestBodySchema } from '../common/schemas/routes/live_query';
|
||||
|
||||
export interface OsqueryPluginSetup {
|
||||
osqueryCreateAction: (payload: CreateLiveQueryRequestBodySchema) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface OsqueryPluginSetup {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface OsqueryPluginStart {}
|
||||
|
||||
|
|
|
@ -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 './response_actions';
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
|
||||
export const OsqueryParams = t.intersection([
|
||||
t.type({
|
||||
id: t.string,
|
||||
}),
|
||||
t.partial({
|
||||
query: t.union([t.string, t.undefined]),
|
||||
ecs_mapping: t.record(t.string, t.record(t.string, t.any)),
|
||||
queries: t.array(
|
||||
t.intersection([
|
||||
t.type({
|
||||
id: t.string,
|
||||
query: t.string,
|
||||
}),
|
||||
t.partial({
|
||||
ecs_mapping: t.record(t.string, t.record(t.string, t.any)),
|
||||
platform: t.union([t.string, t.undefined]),
|
||||
interval: t.union([t.number, t.undefined]),
|
||||
}),
|
||||
])
|
||||
),
|
||||
packId: t.union([t.string, t.undefined]),
|
||||
savedQueryId: t.union([t.string, t.undefined]),
|
||||
}),
|
||||
]);
|
|
@ -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 * as t from 'io-ts';
|
||||
import { OsqueryParams } from './osquery';
|
||||
|
||||
export enum RESPONSE_ACTION_TYPES {
|
||||
OSQUERY = '.osquery',
|
||||
}
|
||||
|
||||
export const SUPPORTED_RESPONSE_ACTION_TYPES = Object.values(RESPONSE_ACTION_TYPES);
|
||||
|
||||
// When we create new response action types, create a union of types
|
||||
const ResponseActionRuleParam = t.exact(
|
||||
t.type({
|
||||
actionTypeId: t.literal(RESPONSE_ACTION_TYPES.OSQUERY),
|
||||
params: OsqueryParams,
|
||||
})
|
||||
);
|
||||
export type RuleResponseAction = t.TypeOf<typeof ResponseActionRuleParam>;
|
||||
|
||||
export const ResponseActionRuleParamsOrUndefined = t.union([
|
||||
t.array(ResponseActionRuleParam),
|
||||
t.undefined,
|
||||
]);
|
||||
|
||||
// When we create new response action types, create a union of types
|
||||
const ResponseAction = t.exact(
|
||||
t.type({
|
||||
action_type_id: t.literal(RESPONSE_ACTION_TYPES.OSQUERY),
|
||||
params: OsqueryParams,
|
||||
})
|
||||
);
|
||||
|
||||
export const ResponseActionArray = t.array(ResponseAction);
|
||||
|
||||
export type ResponseAction = t.TypeOf<typeof ResponseAction>;
|
|
@ -77,6 +77,7 @@ import {
|
|||
newTermsFields,
|
||||
historyWindowStart,
|
||||
} from '../common';
|
||||
import { ResponseActionArray } from '../../rule_response_actions/schemas';
|
||||
|
||||
export const createSchema = <
|
||||
Required extends t.Props,
|
||||
|
@ -295,6 +296,7 @@ const queryRuleParams = {
|
|||
data_view_id,
|
||||
filters,
|
||||
saved_id,
|
||||
response_actions: ResponseActionArray,
|
||||
},
|
||||
defaultable: {
|
||||
query,
|
||||
|
@ -321,6 +323,7 @@ const savedQueryRuleParams = {
|
|||
data_view_id,
|
||||
query,
|
||||
filters,
|
||||
response_actions: ResponseActionArray,
|
||||
},
|
||||
defaultable: {
|
||||
language: t.keyof({ kuery: null, lucene: null }),
|
||||
|
|
|
@ -74,6 +74,7 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): QueryRespo
|
|||
data_view_id: undefined,
|
||||
filters: undefined,
|
||||
saved_id: undefined,
|
||||
response_actions: undefined,
|
||||
});
|
||||
export const getSavedQuerySchemaMock = (
|
||||
anchorDate: string = ANCHOR_DATE
|
||||
|
@ -86,6 +87,7 @@ export const getSavedQuerySchemaMock = (
|
|||
index: undefined,
|
||||
data_view_id: undefined,
|
||||
filters: undefined,
|
||||
response_actions: undefined,
|
||||
});
|
||||
|
||||
export const getRulesMlSchemaMock = (
|
||||
|
|
|
@ -5,7 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { transformRuleToAlertAction, transformAlertToRuleAction } from './transform_actions';
|
||||
import {
|
||||
transformRuleToAlertAction,
|
||||
transformAlertToRuleAction,
|
||||
transformRuleToAlertResponseAction,
|
||||
transformAlertToRuleResponseAction,
|
||||
} from './transform_actions';
|
||||
import { RESPONSE_ACTION_TYPES } from './rule_response_actions/schemas';
|
||||
|
||||
describe('transform_actions', () => {
|
||||
test('it should transform RuleAlertAction[] to RuleAction[]', () => {
|
||||
|
@ -39,4 +45,31 @@ describe('transform_actions', () => {
|
|||
params: alertAction.params,
|
||||
});
|
||||
});
|
||||
test('it should transform ResponseAction[] to RuleResponseAction[]', () => {
|
||||
const ruleAction = {
|
||||
action_type_id: RESPONSE_ACTION_TYPES.OSQUERY,
|
||||
params: {
|
||||
id: 'test',
|
||||
},
|
||||
};
|
||||
const alertAction = transformRuleToAlertResponseAction(ruleAction);
|
||||
expect(alertAction).toEqual({
|
||||
actionTypeId: ruleAction.action_type_id,
|
||||
params: ruleAction.params,
|
||||
});
|
||||
});
|
||||
|
||||
test('it should transform RuleResponseAction[] to ResponseAction[]', () => {
|
||||
const alertAction = {
|
||||
actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY,
|
||||
params: {
|
||||
id: 'test',
|
||||
},
|
||||
};
|
||||
const ruleAction = transformAlertToRuleResponseAction(alertAction);
|
||||
expect(ruleAction).toEqual({
|
||||
action_type_id: alertAction.actionTypeId,
|
||||
params: alertAction.params,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { RuleAction } from '@kbn/alerting-plugin/common';
|
||||
import type { ResponseAction, RuleResponseAction } from './rule_response_actions/schemas';
|
||||
import type { RuleAlertAction } from './types';
|
||||
|
||||
export const transformRuleToAlertAction = ({
|
||||
|
@ -31,3 +32,23 @@ export const transformAlertToRuleAction = ({
|
|||
params,
|
||||
action_type_id: actionTypeId,
|
||||
});
|
||||
|
||||
export const transformRuleToAlertResponseAction = ({
|
||||
action_type_id: actionTypeId,
|
||||
params,
|
||||
}: ResponseAction): RuleResponseAction => {
|
||||
return {
|
||||
params,
|
||||
actionTypeId,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformAlertToRuleResponseAction = ({
|
||||
actionTypeId,
|
||||
params,
|
||||
}: RuleResponseAction): ResponseAction => {
|
||||
return {
|
||||
params,
|
||||
action_type_id: actionTypeId,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -55,6 +55,11 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
* Enables the SOC trends timerange and stats on D&R page
|
||||
*/
|
||||
socTrendsEnabled: false,
|
||||
|
||||
/**
|
||||
* Enables the detection response actions in rule + alerts
|
||||
*/
|
||||
responseActionsEnabled: true,
|
||||
});
|
||||
|
||||
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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, { useCallback } from 'react';
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import type { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
|
||||
const TimelineComponent = React.memo((props) => {
|
||||
return <EuiButtonEmpty {...props} size="xs" />;
|
||||
});
|
||||
TimelineComponent.displayName = 'TimelineComponent';
|
||||
|
||||
export const useHandleAddToTimeline = () => {
|
||||
const {
|
||||
services: { timelines },
|
||||
} = useKibana();
|
||||
const { getAddToTimelineButton } = timelines.getHoverActions();
|
||||
|
||||
return useCallback(
|
||||
(payload: { query: [string, string]; isIcon?: true }) => {
|
||||
const {
|
||||
query: [field, value],
|
||||
isIcon,
|
||||
} = payload;
|
||||
const providerA: DataProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: value,
|
||||
kqlQuery: '',
|
||||
name: value,
|
||||
queryMatch: {
|
||||
field,
|
||||
value,
|
||||
operator: ':',
|
||||
},
|
||||
};
|
||||
|
||||
return getAddToTimelineButton({
|
||||
dataProvider: providerA,
|
||||
field: value,
|
||||
ownFocus: false,
|
||||
...(isIcon ? { showTooltip: true } : { Component: TimelineComponent }),
|
||||
});
|
||||
},
|
||||
[getAddToTimelineButton]
|
||||
);
|
||||
};
|
|
@ -195,4 +195,42 @@ describe('EventDetails', () => {
|
|||
expect(alertsWrapper.find('[data-test-subj="threatIntelTab"]').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('osquery tab', () => {
|
||||
it('should not be rendered if not provided with specific raw data', () => {
|
||||
expect(alertsWrapper.find('[data-test-subj="osqueryViewTab"]').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
it('render osquery tab', async () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
rawEventData: {
|
||||
...rawEventData,
|
||||
fields: {
|
||||
...rawEventData.fields,
|
||||
'agent.id': ['testAgent'],
|
||||
'kibana.alert.rule.name': ['test-rule'],
|
||||
'kibana.alert.rule.parameters': [
|
||||
{
|
||||
response_actions: [{ action_type_id: '.osquery' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<EventDetails {...newProps} />
|
||||
</TestProviders>
|
||||
) as ReactWrapper;
|
||||
alertsWrapper = mount(
|
||||
<TestProviders>
|
||||
<EventDetails {...{ ...alertsProps, ...newProps }} />
|
||||
</TestProviders>
|
||||
) as ReactWrapper;
|
||||
await waitFor(() => wrapper.update());
|
||||
|
||||
expect(alertsWrapper.find('[data-test-subj="osqueryViewTab"]').exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||
import styled from 'styled-components';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { useOsqueryTab } from './osquery_tab';
|
||||
import { EventFieldsBrowser } from './event_fields_browser';
|
||||
import { JsonView } from './json_view';
|
||||
import { ThreatSummaryView } from './cti_details/threat_summary_view';
|
||||
|
@ -50,16 +51,34 @@ export const EVENT_DETAILS_CONTEXT_ID = 'event-details';
|
|||
|
||||
type EventViewTab = EuiTabbedContentTab;
|
||||
|
||||
export interface AlertRawEventData {
|
||||
_id: string;
|
||||
fields: {
|
||||
['agent.id']?: string[];
|
||||
['kibana.alert.rule.parameters']: Array<{
|
||||
response_actions: Array<{
|
||||
action_type_id: string;
|
||||
params: Record<string, unknown>;
|
||||
}>;
|
||||
}>;
|
||||
['kibana.alert.rule.name']: string[];
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type EventViewId =
|
||||
| EventsViewType.tableView
|
||||
| EventsViewType.jsonView
|
||||
| EventsViewType.summaryView
|
||||
| EventsViewType.threatIntelView;
|
||||
| EventsViewType.threatIntelView
|
||||
| EventsViewType.osqueryView;
|
||||
|
||||
export enum EventsViewType {
|
||||
tableView = 'table-view',
|
||||
jsonView = 'json-view',
|
||||
summaryView = 'summary-view',
|
||||
threatIntelView = 'threat-intel-view',
|
||||
osqueryView = 'osquery-results-view',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
@ -89,10 +108,12 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)`
|
|||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
@ -374,11 +395,16 @@ const EventDetailsComponent: React.FC<Props> = ({
|
|||
[rawEventData]
|
||||
);
|
||||
|
||||
const osqueryTab = useOsqueryTab({
|
||||
rawEventData: rawEventData as AlertRawEventData,
|
||||
id,
|
||||
});
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return [summaryTab, threatIntelTab, tableTab, jsonTab].filter(
|
||||
return [summaryTab, threatIntelTab, tableTab, jsonTab, osqueryTab].filter(
|
||||
(tab: EventViewTab | undefined): tab is EventViewTab => !!tab
|
||||
);
|
||||
}, [summaryTab, threatIntelTab, tableTab, jsonTab]);
|
||||
}, [summaryTab, threatIntelTab, tableTab, jsonTab, osqueryTab]);
|
||||
|
||||
const selectedTab = useMemo(
|
||||
() => tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0],
|
||||
|
@ -395,7 +421,6 @@ const EventDetailsComponent: React.FC<Props> = ({
|
|||
/>
|
||||
);
|
||||
};
|
||||
|
||||
EventDetailsComponent.displayName = 'EventDetailsComponent';
|
||||
|
||||
export const EventDetails = React.memo(EventDetailsComponent);
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { RESPONSE_ACTION_TYPES } from '../../../../common/detection_engine/rule_response_actions/schemas';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import type { AlertRawEventData } from './event_details';
|
||||
import { EventsViewType } from './event_details';
|
||||
import * as i18n from './translations';
|
||||
import { useHandleAddToTimeline } from './add_to_timeline_button';
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
height: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const useOsqueryTab = ({
|
||||
rawEventData,
|
||||
id,
|
||||
}: {
|
||||
rawEventData?: AlertRawEventData;
|
||||
id: string;
|
||||
}) => {
|
||||
const {
|
||||
services: { osquery },
|
||||
} = useKibana();
|
||||
const handleAddToTimeline = useHandleAddToTimeline();
|
||||
const responseActionsEnabled = useIsExperimentalFeatureEnabled('responseActionsEnabled');
|
||||
if (!osquery || !rawEventData || !responseActionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { OsqueryResults } = osquery;
|
||||
const parameters = rawEventData.fields['kibana.alert.rule.parameters'];
|
||||
const responseActions = parameters?.[0].response_actions;
|
||||
|
||||
const osqueryActionsLength = responseActions?.filter(
|
||||
(action: { action_type_id: string }) => action.action_type_id === RESPONSE_ACTION_TYPES.OSQUERY
|
||||
)?.length;
|
||||
|
||||
const agentIds = rawEventData.fields['agent.id'];
|
||||
const ruleName = rawEventData.fields['kibana.alert.rule.name'];
|
||||
|
||||
const alertId = rawEventData._id;
|
||||
return osqueryActionsLength
|
||||
? {
|
||||
id: EventsViewType.osqueryView,
|
||||
'data-test-subj': 'osqueryViewTab',
|
||||
name: (
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
alignItems={'center'}
|
||||
justifyContent={'spaceAround'}
|
||||
gutterSize="xs"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<span>{i18n.OSQUERY_VIEW}</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiNotificationBadge data-test-subj="osquery-actions-notification">
|
||||
{osqueryActionsLength}
|
||||
</EuiNotificationBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
content: (
|
||||
<>
|
||||
<TabContentWrapper data-test-subj="osqueryViewWrapper">
|
||||
<OsqueryResults
|
||||
agentIds={agentIds}
|
||||
ruleName={ruleName}
|
||||
alertId={alertId}
|
||||
addToTimeline={handleAddToTimeline}
|
||||
/>
|
||||
</TabContentWrapper>
|
||||
</>
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
};
|
|
@ -66,6 +66,10 @@ export const JSON_VIEW = i18n.translate('xpack.securitySolution.eventDetails.jso
|
|||
defaultMessage: 'JSON',
|
||||
});
|
||||
|
||||
export const OSQUERY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.osqueryView', {
|
||||
defaultMessage: 'Osquery Results',
|
||||
});
|
||||
|
||||
export const FIELD = i18n.translate('xpack.securitySolution.eventDetails.field', {
|
||||
defaultMessage: 'Field',
|
||||
});
|
||||
|
|
|
@ -53,6 +53,9 @@ export const useKibana = jest.fn().mockReturnValue({
|
|||
},
|
||||
},
|
||||
},
|
||||
osquery: {
|
||||
OsqueryResults: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
timelines: createTGridMocks(),
|
||||
savedObjectsTagging: {
|
||||
ui: {
|
||||
|
|
|
@ -162,6 +162,12 @@ export const createStartServicesMock = (
|
|||
timelines: {
|
||||
getLastUpdated: jest.fn(),
|
||||
getFieldBrowser: jest.fn(),
|
||||
getHoverActions: jest.fn().mockReturnValue({
|
||||
getAddToTimelineButton: jest.fn(),
|
||||
}),
|
||||
},
|
||||
osquery: {
|
||||
OsqueryResults: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
triggersActionsUi,
|
||||
} as unknown as StartServices;
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 { RESPONSE_ACTION_TYPES } from '../../../common/detection_engine/rule_response_actions/schemas';
|
||||
|
||||
export const getActionDetails = (actionTypeId: string) => {
|
||||
switch (actionTypeId) {
|
||||
case RESPONSE_ACTION_TYPES.OSQUERY:
|
||||
return { logo: 'logoOsquery', name: 'Osquery' };
|
||||
// update when new responseActions are provided
|
||||
default:
|
||||
return { logo: 'logoOsquery', name: 'Osquery' };
|
||||
}
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 {
|
||||
SUPPORTED_RESPONSE_ACTION_TYPES,
|
||||
RESPONSE_ACTION_TYPES,
|
||||
} from '../../../common/detection_engine/rule_response_actions/schemas';
|
||||
|
||||
export interface ResponseActionType {
|
||||
id: RESPONSE_ACTION_TYPES;
|
||||
name: string;
|
||||
iconClass: string;
|
||||
}
|
||||
|
||||
export const getSupportedResponseActions = (
|
||||
actionTypes: ResponseActionType[]
|
||||
): ResponseActionType[] => {
|
||||
return actionTypes.filter((actionType) => {
|
||||
return SUPPORTED_RESPONSE_ACTION_TYPES.includes(actionType.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const responseActionTypes = [
|
||||
{
|
||||
id: RESPONSE_ACTION_TYPES.OSQUERY,
|
||||
name: 'osquery',
|
||||
iconClass: 'logoOsquery',
|
||||
},
|
||||
// { id: '.endpointSecurity', name: 'endpointSecurity', iconClass: 'logoSecurity' },
|
||||
];
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { EuiCode, EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { ResponseActionValidatorRef } from '../response_actions_form';
|
||||
import type { ArrayItem } from '../../../shared_imports';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { NOT_AVAILABLE, PERMISSION_DENIED, SHORT_EMPTY_TITLE } from './translations';
|
||||
|
||||
interface IProps {
|
||||
item: ArrayItem;
|
||||
formRef: React.RefObject<ResponseActionValidatorRef>;
|
||||
}
|
||||
|
||||
export const OsqueryResponseAction = React.memo((props: IProps) => {
|
||||
const { osquery } = useKibana().services;
|
||||
const OsqueryForm = useMemo(
|
||||
() => osquery?.OsqueryResponseActionTypeForm,
|
||||
[osquery?.OsqueryResponseActionTypeForm]
|
||||
);
|
||||
|
||||
if (osquery) {
|
||||
const { disabled, permissionDenied } = osquery?.fetchInstallationStatus();
|
||||
|
||||
if (permissionDenied) {
|
||||
return (
|
||||
<>
|
||||
<EuiEmptyPrompt
|
||||
title={<h2>{PERMISSION_DENIED}</h2>}
|
||||
titleSize="xs"
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.osquery.action.missingPrivilleges"
|
||||
defaultMessage="To access this page, ask your administrator for {osquery} Kibana privileges."
|
||||
values={{
|
||||
// TODO fix error
|
||||
// eslint-disable-next-line react/jsx-no-literals
|
||||
osquery: <EuiCode>osquery</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
title={<h2>{SHORT_EMPTY_TITLE}</h2>}
|
||||
titleSize="xs"
|
||||
body={<p>{NOT_AVAILABLE}</p>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (OsqueryForm) {
|
||||
return <OsqueryForm {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
OsqueryResponseAction.displayName = 'OsqueryResponseAction';
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const SHORT_EMPTY_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.osquery.action.shortEmptyTitle',
|
||||
{
|
||||
defaultMessage: 'Osquery is not available',
|
||||
}
|
||||
);
|
||||
|
||||
export const PERMISSION_DENIED = i18n.translate(
|
||||
'xpack.securitySolution.osquery.action.permissionDenied',
|
||||
{
|
||||
defaultMessage: 'Permission denied',
|
||||
}
|
||||
);
|
||||
|
||||
export const NOT_AVAILABLE = i18n.translate('xpack.securitySolution.osquery.action.unavailable', {
|
||||
defaultMessage:
|
||||
'The Osquery Manager integration is not added to the agent policy. To run queries on the host, add the Osquery Manager integration to the agent policy in Fleet.',
|
||||
});
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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, { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiKeyPadMenuItem,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useLicense } from '../../common/hooks/use_license';
|
||||
import type { ResponseActionType } from './get_supported_response_actions';
|
||||
import { useFormData } from '../../shared_imports';
|
||||
|
||||
interface IResponseActionsAddButtonProps {
|
||||
supportedResponseActionTypes: ResponseActionType[];
|
||||
addActionType: () => void;
|
||||
updateActionTypeId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ResponseActionAddButton = ({
|
||||
supportedResponseActionTypes,
|
||||
addActionType,
|
||||
updateActionTypeId,
|
||||
}: IResponseActionsAddButtonProps) => {
|
||||
const [data] = useFormData();
|
||||
const [isAddResponseActionButtonShown, setAddResponseActionButtonShown] = useState(
|
||||
data.responseActions && data.responseActions.length > 0
|
||||
);
|
||||
const isGoldLicense = useLicense().isGoldPlus();
|
||||
|
||||
const handleAddActionType = useCallback(
|
||||
(item) => {
|
||||
setAddResponseActionButtonShown(false);
|
||||
addActionType();
|
||||
|
||||
updateActionTypeId(item.id);
|
||||
},
|
||||
[addActionType, updateActionTypeId]
|
||||
);
|
||||
|
||||
const renderAddResponseActionButton = useMemo(() => {
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiButton
|
||||
size="s"
|
||||
data-test-subj="addAlertActionButton"
|
||||
onClick={() => setAddResponseActionButtonShown(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.sections.actionForm.addResponseActionButtonLabel"
|
||||
defaultMessage="Add response action"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderResponseActionTypes = useMemo(() => {
|
||||
return (
|
||||
supportedResponseActionTypes?.length &&
|
||||
supportedResponseActionTypes.map(function (item, index) {
|
||||
const keyPadItem = (
|
||||
<EuiKeyPadMenuItem
|
||||
key={index}
|
||||
isDisabled={!isGoldLicense}
|
||||
data-test-subj={`${item.id}-ResponseActionTypeSelectOption`}
|
||||
label={item.name}
|
||||
onClick={() => handleAddActionType(item)}
|
||||
>
|
||||
<EuiIcon size="xl" type={item.iconClass} />
|
||||
</EuiKeyPadMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={false} key={`keypad-${item.id}`}>
|
||||
{keyPadItem}
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})
|
||||
);
|
||||
}, [handleAddActionType, isGoldLicense, supportedResponseActionTypes]);
|
||||
|
||||
if (!supportedResponseActionTypes?.length) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAddResponseActionButtonShown ? renderAddResponseActionButton : renderResponseActionTypes}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { get } from 'lodash';
|
||||
import { RESPONSE_ACTION_TYPES } from '../../../common/detection_engine/rule_response_actions/schemas';
|
||||
import type { ResponseActionValidatorRef } from './response_actions_form';
|
||||
import { OsqueryResponseAction } from './osquery/osquery_response_action';
|
||||
import { getActionDetails } from './constants';
|
||||
import { useFormData } from '../../shared_imports';
|
||||
import type { ArrayItem } from '../../shared_imports';
|
||||
|
||||
interface IProps {
|
||||
item: ArrayItem;
|
||||
onDeleteAction: (id: number) => void;
|
||||
formRef: React.RefObject<ResponseActionValidatorRef>;
|
||||
}
|
||||
|
||||
export const ResponseActionTypeForm = React.memo((props: IProps) => {
|
||||
const { item, onDeleteAction, formRef } = props;
|
||||
const [_isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const [data] = useFormData();
|
||||
const action = get(data, item.path);
|
||||
|
||||
const getResponseActionTypeForm = useCallback(() => {
|
||||
if (action?.actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY) {
|
||||
return <OsqueryResponseAction item={item} formRef={formRef} />;
|
||||
}
|
||||
// Place for other ResponseActionTypes
|
||||
return null;
|
||||
}, [action?.actionTypeId, formRef, item]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDeleteAction(item.id);
|
||||
}, [item, onDeleteAction]);
|
||||
|
||||
const renderButtonContent = useMemo(() => {
|
||||
const { logo, name } = getActionDetails(action?.actionTypeId);
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="l" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={logo} size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>{name}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}, [action?.actionTypeId]);
|
||||
|
||||
const renderExtraContent = useMemo(() => {
|
||||
return (
|
||||
<EuiButtonIcon
|
||||
iconType="minusInCircle"
|
||||
color="danger"
|
||||
className="actAccordionActionForm__extraAction"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.securitySolution.actionTypeForm.accordion.deleteIconAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Delete',
|
||||
}
|
||||
)}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
);
|
||||
}, [handleDelete]);
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen={true}
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
onToggle={setIsOpen}
|
||||
paddingSize="l"
|
||||
className="actAccordionActionForm"
|
||||
buttonContentClassName="actAccordionActionForm__button"
|
||||
data-test-subj={`alertActionAccordion`}
|
||||
buttonContent={renderButtonContent}
|
||||
extraAction={renderExtraContent}
|
||||
>
|
||||
{getResponseActionTypeForm()}
|
||||
</EuiAccordion>
|
||||
);
|
||||
});
|
||||
|
||||
ResponseActionTypeForm.displayName = 'ResponseActionTypeForm';
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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, { useRef } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { ResponseActionsForm } from './response_actions_form';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
|
||||
const renderWithContext = (Element: React.ReactElement) => {
|
||||
return render(<IntlProvider locale={'en'}>{Element}</IntlProvider>);
|
||||
};
|
||||
|
||||
describe('ResponseActionsForm', () => {
|
||||
const Component = (props: { items: ArrayItem[] }) => {
|
||||
const { form } = useForm();
|
||||
const saveClickRef = useRef<{ onSaveClick: () => Promise<boolean> | null }>({
|
||||
onSaveClick: () => null,
|
||||
});
|
||||
return (
|
||||
<Form form={form}>
|
||||
<ResponseActionsForm
|
||||
addItem={jest.fn()}
|
||||
removeItem={jest.fn()}
|
||||
saveClickRef={saveClickRef}
|
||||
{...props}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
it('renders correctly', async () => {
|
||||
const { getByTestId, queryByTestId } = renderWithContext(<Component items={[]} />);
|
||||
expect(getByTestId('response-actions-form'));
|
||||
expect(getByTestId('response-actions-header'));
|
||||
expect(getByTestId('response-actions-list'));
|
||||
expect(queryByTestId('response-actions-list-item-0')).toEqual(null);
|
||||
});
|
||||
it('renders list of elements', async () => {
|
||||
const { getByTestId, queryByTestId } = renderWithContext(
|
||||
<Component
|
||||
items={[
|
||||
{ path: '1', id: 1, isNew: false },
|
||||
{ path: '2', id: 2, isNew: false },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const list = getByTestId('response-actions-list');
|
||||
expect(getByTestId('response-actions-form'));
|
||||
expect(getByTestId('response-actions-header'));
|
||||
expect(list);
|
||||
expect(queryByTestId('response-actions-list-item-0')).not.toEqual(null);
|
||||
expect(queryByTestId('response-actions-list-item-1')).not.toEqual(null);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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, { useEffect, useMemo, useRef } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { isEmpty, map, some } from 'lodash';
|
||||
import { ResponseActionsHeader } from './response_actions_header';
|
||||
import { ResponseActionsList } from './response_actions_list';
|
||||
|
||||
import type { ArrayItem } from '../../shared_imports';
|
||||
import { useSupportedResponseActionTypes } from './use_supported_response_action_types';
|
||||
|
||||
export interface ResponseActionValidatorRef {
|
||||
validation: {
|
||||
[key: string]: () => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
items: ArrayItem[];
|
||||
addItem: () => void;
|
||||
removeItem: (id: number) => void;
|
||||
saveClickRef: React.RefObject<{
|
||||
onSaveClick?: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const ResponseActionsForm = ({ items, addItem, removeItem, saveClickRef }: IProps) => {
|
||||
const responseActionsValidationRef = useRef<ResponseActionValidatorRef>({ validation: {} });
|
||||
const supportedResponseActionTypes = useSupportedResponseActionTypes();
|
||||
|
||||
useEffect(() => {
|
||||
if (saveClickRef && saveClickRef.current) {
|
||||
saveClickRef.current.onSaveClick = () => {
|
||||
return validateResponseActions();
|
||||
};
|
||||
}
|
||||
}, [saveClickRef]);
|
||||
|
||||
const validateResponseActions = async () => {
|
||||
if (!isEmpty(responseActionsValidationRef.current?.validation)) {
|
||||
const response = await Promise.all(
|
||||
map(responseActionsValidationRef.current?.validation, async (validation) => {
|
||||
return validation();
|
||||
})
|
||||
);
|
||||
|
||||
return some(response, (val) => !val);
|
||||
}
|
||||
};
|
||||
|
||||
const form = useMemo(() => {
|
||||
if (!supportedResponseActionTypes?.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ResponseActionsList
|
||||
items={items}
|
||||
removeItem={removeItem}
|
||||
supportedResponseActionTypes={supportedResponseActionTypes}
|
||||
addItem={addItem}
|
||||
formRef={responseActionsValidationRef}
|
||||
/>
|
||||
);
|
||||
}, [addItem, responseActionsValidationRef, items, removeItem, supportedResponseActionTypes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="xxl" data-test-subj={'response-actions-form'} />
|
||||
<ResponseActionsHeader />
|
||||
{form}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const ResponseActionsHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
alignItems="center"
|
||||
responsive={false}
|
||||
data-test-subj={'response-actions-header'}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
defaultMessage="Response Actions"
|
||||
id="xpack.securitySolution.actionForm.responseActionSectionsDescription"
|
||||
/>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBetaBadge
|
||||
tooltipContent={i18n.translate(
|
||||
'xpack.securitySolution.actionForm.experimentalTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
|
||||
}
|
||||
)}
|
||||
label={i18n.translate('xpack.securitySolution.rules.actionForm.experimentalTitle', {
|
||||
defaultMessage: 'Technical preview',
|
||||
})}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexItem>
|
||||
<FormattedMessage
|
||||
defaultMessage="Response actions are run on each rule execution"
|
||||
id="xpack.securitySolution.actionForm.responseActionSectionsTitle"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* 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, { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import type { ResponseActionValidatorRef } from './response_actions_form';
|
||||
import type { ResponseActionType } from './get_supported_response_actions';
|
||||
import { ResponseActionAddButton } from './response_action_add_button';
|
||||
import { ResponseActionTypeForm } from './response_action_type_form';
|
||||
import type { ArrayItem } from '../../shared_imports';
|
||||
import { UseField, useFormContext } from '../../shared_imports';
|
||||
|
||||
interface IResponseActionsListProps {
|
||||
items: ArrayItem[];
|
||||
removeItem: (id: number) => void;
|
||||
addItem: () => void;
|
||||
supportedResponseActionTypes: ResponseActionType[];
|
||||
formRef: React.RefObject<ResponseActionValidatorRef>;
|
||||
}
|
||||
|
||||
const GhostFormField = () => <></>;
|
||||
|
||||
export const ResponseActionsList = React.memo(
|
||||
({
|
||||
items,
|
||||
removeItem,
|
||||
supportedResponseActionTypes,
|
||||
addItem,
|
||||
formRef,
|
||||
}: IResponseActionsListProps) => {
|
||||
const actionTypeIdRef = useRef<string | null>(null);
|
||||
const updateActionTypeId = useCallback((id) => {
|
||||
actionTypeIdRef.current = id;
|
||||
}, []);
|
||||
|
||||
const context = useFormContext();
|
||||
const renderButton = useMemo(() => {
|
||||
return (
|
||||
<ResponseActionAddButton
|
||||
supportedResponseActionTypes={supportedResponseActionTypes}
|
||||
addActionType={addItem}
|
||||
updateActionTypeId={updateActionTypeId}
|
||||
/>
|
||||
);
|
||||
}, [addItem, updateActionTypeId, supportedResponseActionTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionTypeIdRef.current) {
|
||||
const index = items.length - 1;
|
||||
const path = `responseActions[${index}].actionTypeId`;
|
||||
context.setFieldValue(path, actionTypeIdRef.current);
|
||||
actionTypeIdRef.current = null;
|
||||
}
|
||||
}, [context, items.length]);
|
||||
return (
|
||||
<div data-test-subj={'response-actions-list'}>
|
||||
{items.map((actionItem, index) => {
|
||||
return (
|
||||
<div key={actionItem.id} data-test-subj={`response-actions-list-item-${index}`}>
|
||||
<EuiSpacer size="m" />
|
||||
<ResponseActionTypeForm
|
||||
item={actionItem}
|
||||
onDeleteAction={removeItem}
|
||||
formRef={formRef}
|
||||
/>
|
||||
|
||||
<UseField path={`${actionItem.path}.actionTypeId`} component={GhostFormField} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{renderButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ResponseActionsList.displayName = 'ResponseActionsList';
|
|
@ -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 { useKibana } from '../../common/lib/kibana';
|
||||
|
||||
export const useOsqueryEnabled = () => {
|
||||
const { osquery } = useKibana().services;
|
||||
|
||||
const osqueryStatus = osquery?.fetchInstallationStatus();
|
||||
return !osqueryStatus?.loading && !osqueryStatus?.disabled && !osqueryStatus?.permissionDenied;
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
import type { ResponseActionType } from './get_supported_response_actions';
|
||||
import { getSupportedResponseActions, responseActionTypes } from './get_supported_response_actions';
|
||||
import { useOsqueryEnabled } from './use_osquery_enabled';
|
||||
|
||||
export const useSupportedResponseActionTypes = () => {
|
||||
const [supportedResponseActionTypes, setSupportedResponseActionTypes] = useState<
|
||||
ResponseActionType[] | undefined
|
||||
>();
|
||||
|
||||
const isOsqueryEnabled = useOsqueryEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
const supportedTypes = getSupportedResponseActions(responseActionTypes);
|
||||
setSupportedResponseActionTypes(supportedTypes);
|
||||
}, [isOsqueryEnabled]);
|
||||
|
||||
return supportedResponseActionTypes;
|
||||
};
|
|
@ -5,20 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiButtonEmpty,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { EuiFlyout, EuiFlyoutFooter, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
|
||||
import { useHandleAddToTimeline } from '../../../common/components/event_details/add_to_timeline_button';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { OsqueryEventDetailsFooter } from './osquery_flyout_footer';
|
||||
import { ACTION_OSQUERY } from './translations';
|
||||
import type { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
|
||||
|
||||
const OsqueryActionWrapper = styled.div`
|
||||
padding: 8px;
|
||||
|
@ -29,50 +22,16 @@ export interface OsqueryFlyoutProps {
|
|||
defaultValues?: {};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TimelineComponent = React.memo((props) => <EuiButtonEmpty {...props} size="xs" />);
|
||||
TimelineComponent.displayName = 'TimelineComponent';
|
||||
|
||||
export const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
|
||||
agentId,
|
||||
defaultValues,
|
||||
onClose,
|
||||
}) => {
|
||||
const {
|
||||
services: { osquery, timelines },
|
||||
services: { osquery },
|
||||
} = useKibana();
|
||||
|
||||
const { getAddToTimelineButton } = timelines.getHoverActions();
|
||||
|
||||
const handleAddToTimeline = useCallback(
|
||||
(payload: { query: [string, string]; isIcon?: true }) => {
|
||||
const {
|
||||
query: [field, value],
|
||||
isIcon,
|
||||
} = payload;
|
||||
const providerA: DataProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: value,
|
||||
kqlQuery: '',
|
||||
name: value,
|
||||
queryMatch: {
|
||||
field,
|
||||
value,
|
||||
operator: ':',
|
||||
},
|
||||
};
|
||||
|
||||
return getAddToTimelineButton({
|
||||
dataProvider: providerA,
|
||||
field: value,
|
||||
ownFocus: false,
|
||||
...(isIcon ? { showTooltip: true } : { Component: TimelineComponent }),
|
||||
});
|
||||
},
|
||||
[getAddToTimelineButton]
|
||||
);
|
||||
const handleAddToTimeline = useHandleAddToTimeline();
|
||||
|
||||
if (osquery?.OsqueryAction) {
|
||||
return (
|
||||
|
|
|
@ -98,18 +98,20 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
|
|||
const setActionParamsProperty = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(key: string, value: any, index: number) => {
|
||||
const updatedActions = [...actions];
|
||||
updatedActions[index] = {
|
||||
...updatedActions[index],
|
||||
params: {
|
||||
...updatedActions[index].params,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
field.setValue(updatedActions);
|
||||
field.setValue((prevValue: RuleAction[]) => {
|
||||
const updatedActions = [...prevValue];
|
||||
updatedActions[index] = {
|
||||
...updatedActions[index],
|
||||
params: {
|
||||
...updatedActions[index].params,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
return updatedActions;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[field.setValue, actions]
|
||||
[field.setValue]
|
||||
);
|
||||
|
||||
const actionForm = useMemo(
|
||||
|
|
|
@ -31,6 +31,10 @@ jest.mock('../../../../common/lib/kibana', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const actionMessageParams = {
|
||||
context: [],
|
||||
state: [],
|
||||
|
|
|
@ -16,9 +16,14 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { findIndex } from 'lodash/fp';
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { UseArray } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import { isQueryRule } from '../../../../../common/detection_engine/utils';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { ResponseActionsForm } from '../../../../detection_engine/rule_response_actions/response_actions_form';
|
||||
import type { RuleStepProps, ActionsStepRule } from '../../../pages/detection_engine/rules/types';
|
||||
import { RuleStep } from '../../../pages/detection_engine/rules/types';
|
||||
import { StepRuleDescription } from '../description_step';
|
||||
|
@ -39,11 +44,13 @@ import { useManageCaseAction } from './use_manage_case_action';
|
|||
interface StepRuleActionsProps extends RuleStepProps {
|
||||
defaultValues?: ActionsStepRule | null;
|
||||
actionMessageParams: ActionVariables;
|
||||
ruleType?: Type;
|
||||
}
|
||||
|
||||
export const stepActionsDefaultValue: ActionsStepRule = {
|
||||
enabled: true,
|
||||
actions: [],
|
||||
responseActions: [],
|
||||
kibanaSiemAppUrl: '',
|
||||
throttle: DEFAULT_THROTTLE_OPTION.value,
|
||||
};
|
||||
|
@ -68,6 +75,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
onSubmit,
|
||||
setForm,
|
||||
actionMessageParams,
|
||||
ruleType,
|
||||
}) => {
|
||||
const [isLoadingCaseAction] = useManageCaseAction();
|
||||
const {
|
||||
|
@ -76,6 +84,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
triggersActionsUi: { actionTypeRegistry },
|
||||
},
|
||||
} = useKibana();
|
||||
const responseActionsEnabled = useIsExperimentalFeatureEnabled('responseActionsEnabled');
|
||||
const kibanaAbsoluteUrl = useMemo(
|
||||
() =>
|
||||
application.getUrlForApp(`${APP_UI_ID}`, {
|
||||
|
@ -83,6 +92,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
}),
|
||||
[application]
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
...(defaultValues ?? stepActionsDefaultValue),
|
||||
kibanaSiemAppUrl: kibanaAbsoluteUrl,
|
||||
|
@ -111,7 +121,19 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
[getFields, onSubmit]
|
||||
);
|
||||
|
||||
const saveClickRef = useRef<{ onSaveClick: () => Promise<boolean> | null }>({
|
||||
onSaveClick: () => null,
|
||||
});
|
||||
|
||||
const getData = useCallback(async () => {
|
||||
const isResponseActionsInvalid = await saveClickRef.current.onSaveClick();
|
||||
if (isResponseActionsInvalid) {
|
||||
return {
|
||||
isValid: false,
|
||||
data: getFormData(),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await submit();
|
||||
return result?.isValid
|
||||
? result
|
||||
|
@ -167,6 +189,18 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
),
|
||||
[throttle, actionMessageParams]
|
||||
);
|
||||
const displayResponseActionsOptions = useMemo(() => {
|
||||
if (isQueryRule(ruleType)) {
|
||||
return (
|
||||
<>
|
||||
<UseArray path="responseActions">
|
||||
{(params) => <ResponseActionsForm {...params} saveClickRef={saveClickRef} />}
|
||||
</UseArray>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [ruleType]);
|
||||
// only display the actions dropdown if the user has "read" privileges for actions
|
||||
const displayActionsDropDown = useMemo(() => {
|
||||
return application.capabilities.actions.show ? (
|
||||
|
@ -177,6 +211,8 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
componentProps={throttleFieldComponentProps}
|
||||
/>
|
||||
{displayActionsOptions}
|
||||
{responseActionsEnabled && displayResponseActionsOptions}
|
||||
|
||||
<UseField path="kibanaSiemAppUrl" component={GhostFormField} />
|
||||
<UseField path="enabled" component={GhostFormField} />
|
||||
</>
|
||||
|
@ -193,7 +229,13 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
|
|||
<UseField path="enabled" component={GhostFormField} />
|
||||
</>
|
||||
);
|
||||
}, [application.capabilities.actions.show, displayActionsOptions, throttleFieldComponentProps]);
|
||||
}, [
|
||||
application.capabilities.actions.show,
|
||||
displayActionsOptions,
|
||||
displayResponseActionsOptions,
|
||||
responseActionsEnabled,
|
||||
throttleFieldComponentProps,
|
||||
]);
|
||||
|
||||
if (isReadOnlyView) {
|
||||
return (
|
||||
|
|
|
@ -25,7 +25,11 @@ import type {
|
|||
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
|
||||
import { NOTIFICATION_THROTTLE_NO_ACTIONS } from '../../../../../../common/constants';
|
||||
import { assertUnreachable } from '../../../../../../common/utility_types';
|
||||
import { transformAlertToRuleAction } from '../../../../../../common/detection_engine/transform_actions';
|
||||
import {
|
||||
transformAlertToRuleAction,
|
||||
transformAlertToRuleResponseAction,
|
||||
} from '../../../../../../common/detection_engine/transform_actions';
|
||||
|
||||
import type {
|
||||
AboutStepRule,
|
||||
DefineStepRule,
|
||||
|
@ -545,6 +549,7 @@ export const formatAboutStepData = (
|
|||
export const formatActionsStepData = (actionsStepData: ActionsStepRule): ActionsStepRuleJson => {
|
||||
const {
|
||||
actions = [],
|
||||
responseActions,
|
||||
enabled,
|
||||
kibanaSiemAppUrl,
|
||||
throttle = NOTIFICATION_THROTTLE_NO_ACTIONS,
|
||||
|
@ -552,6 +557,7 @@ export const formatActionsStepData = (actionsStepData: ActionsStepRule): Actions
|
|||
|
||||
return {
|
||||
actions: actions.map(transformAlertToRuleAction),
|
||||
response_actions: responseActions?.map(transformAlertToRuleResponseAction),
|
||||
enabled,
|
||||
throttle: actions.length ? throttle : NOTIFICATION_THROTTLE_NO_ACTIONS,
|
||||
meta: {
|
||||
|
|
|
@ -544,6 +544,7 @@ const CreateRulePageComponent: React.FC = () => {
|
|||
// We need a key to make this component remount when edit/view mode is toggled
|
||||
// https://github.com/elastic/kibana/pull/132834#discussion_r881705566
|
||||
key={isShouldRerenderStep(RuleStep.ruleActions, activeStep)}
|
||||
ruleType={ruleType}
|
||||
/>
|
||||
</EuiAccordion>
|
||||
</MyEuiPanel>
|
||||
|
|
|
@ -290,6 +290,7 @@ const EditRulePageComponent: FC = () => {
|
|||
defaultValues={actionsStep.data}
|
||||
setForm={setFormHook}
|
||||
actionMessageParams={actionMessageParams}
|
||||
ruleType={rule?.type}
|
||||
/>
|
||||
)}
|
||||
<EuiSpacer />
|
||||
|
@ -300,6 +301,7 @@ const EditRulePageComponent: FC = () => {
|
|||
],
|
||||
[
|
||||
rule?.immutable,
|
||||
rule?.type,
|
||||
loading,
|
||||
defineStep.data,
|
||||
isLoading,
|
||||
|
|
|
@ -140,6 +140,7 @@ describe('rule helpers', () => {
|
|||
enabled: true,
|
||||
throttle: 'no_actions',
|
||||
actions: [],
|
||||
responseActions: [],
|
||||
};
|
||||
const aboutRuleDataDetailsData = {
|
||||
note: '# this is some markdown documentation',
|
||||
|
@ -410,6 +411,7 @@ describe('rule helpers', () => {
|
|||
actionTypeId: 'action_type_id',
|
||||
},
|
||||
],
|
||||
responseActions: [],
|
||||
enabled: mockedRule.enabled,
|
||||
throttle: 'no_actions',
|
||||
};
|
||||
|
|
|
@ -21,10 +21,14 @@ import type {
|
|||
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import type { ResponseAction } from '../../../../../common/detection_engine/rule_response_actions/schemas';
|
||||
import { normalizeThresholdField } from '../../../../../common/detection_engine/utils';
|
||||
import type { RuleAlertAction } from '../../../../../common/detection_engine/types';
|
||||
import { assertUnreachable } from '../../../../../common/utility_types';
|
||||
import { transformRuleToAlertAction } from '../../../../../common/detection_engine/transform_actions';
|
||||
import {
|
||||
transformRuleToAlertAction,
|
||||
transformRuleToAlertResponseAction,
|
||||
} from '../../../../../common/detection_engine/transform_actions';
|
||||
import type { Rule } from '../../../containers/detection_engine/rules';
|
||||
import type {
|
||||
AboutStepRule,
|
||||
|
@ -67,12 +71,16 @@ export const getStepsData = ({
|
|||
};
|
||||
|
||||
export const getActionsStepsData = (
|
||||
rule: Omit<Rule, 'actions'> & { actions: RuleAlertAction[] }
|
||||
rule: Omit<Rule, 'actions'> & {
|
||||
actions: RuleAlertAction[];
|
||||
response_actions?: ResponseAction[];
|
||||
}
|
||||
): ActionsStepRule => {
|
||||
const { enabled, throttle, meta, actions = [] } = rule;
|
||||
const { enabled, throttle, meta, actions = [], response_actions: responseActions = [] } = rule;
|
||||
|
||||
return {
|
||||
actions: actions?.map(transformRuleToAlertAction),
|
||||
responseActions: responseActions?.map(transformRuleToAlertResponseAction),
|
||||
throttle,
|
||||
kibanaSiemAppUrl: meta?.kibana_siem_app_url,
|
||||
enabled,
|
||||
|
@ -379,7 +387,7 @@ const commonRuleParamsKeys = [
|
|||
'type',
|
||||
'version',
|
||||
];
|
||||
const queryRuleParams = ['index', 'filters', 'language', 'query', 'saved_id'];
|
||||
const queryRuleParams = ['index', 'filters', 'language', 'query', 'saved_id', 'response_actions'];
|
||||
const machineLearningRuleParams = ['anomaly_threshold', 'machine_learning_job_id'];
|
||||
const thresholdRuleParams = ['threshold', ...queryRuleParams];
|
||||
|
||||
|
|
|
@ -36,6 +36,10 @@ import type {
|
|||
TimestampOverride,
|
||||
} from '../../../../../common/detection_engine/schemas/common';
|
||||
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
|
||||
import type {
|
||||
RuleResponseAction,
|
||||
ResponseAction,
|
||||
} from '../../../../../common/detection_engine/rule_response_actions/schemas';
|
||||
|
||||
export interface EuiBasicTableSortTypes {
|
||||
field: string;
|
||||
|
@ -58,6 +62,7 @@ export enum RuleStep {
|
|||
scheduleRule = 'schedule-rule',
|
||||
ruleActions = 'rule-actions',
|
||||
}
|
||||
|
||||
export type RuleStepsOrder = [
|
||||
RuleStep.defineRule,
|
||||
RuleStep.aboutRule,
|
||||
|
@ -173,6 +178,7 @@ export interface ScheduleStepRule {
|
|||
|
||||
export interface ActionsStepRule {
|
||||
actions: RuleAction[];
|
||||
responseActions?: RuleResponseAction[];
|
||||
enabled: boolean;
|
||||
kibanaSiemAppUrl?: string;
|
||||
throttle?: string | null;
|
||||
|
@ -239,6 +245,7 @@ export interface ScheduleStepRuleJson {
|
|||
|
||||
export interface ActionsStepRuleJson {
|
||||
actions: RuleAlertAction[];
|
||||
response_actions?: ResponseAction[];
|
||||
enabled: boolean;
|
||||
throttle?: string | null;
|
||||
meta?: unknown;
|
||||
|
|
|
@ -13,6 +13,7 @@ export type {
|
|||
FormSchema,
|
||||
ValidationError,
|
||||
ValidationFunc,
|
||||
ArrayItem,
|
||||
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
|
||||
export {
|
||||
getUseField,
|
||||
|
|
|
@ -146,6 +146,14 @@ describe('event details footer component', () => {
|
|||
getCasesContext: () => mockCasesContext,
|
||||
},
|
||||
},
|
||||
timelines: {
|
||||
getHoverActions: jest.fn().mockReturnValue({
|
||||
getAddToTimelineButton: jest.fn(),
|
||||
}),
|
||||
},
|
||||
osquery: {
|
||||
OsqueryResults: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
});
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(allCasesPermissions());
|
||||
|
|
|
@ -11,6 +11,7 @@ import React, { useMemo } from 'react';
|
|||
import deepEqual from 'fast-deep-equal';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { EntityType } from '@kbn/timelines-plugin/common';
|
||||
import type { AlertRawEventData } from '../../../../common/components/event_details/event_details';
|
||||
import type { BrowserFields } from '../../../../common/containers/source';
|
||||
import { ExpandableEvent, ExpandableEventTitle } from './expandable_event';
|
||||
import { useTimelineEventsDetails } from '../../../containers/details';
|
||||
|
@ -127,7 +128,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
isIsolateActionSuccessBannerVisible={isIsolateActionSuccessBannerVisible}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
loading={loading}
|
||||
rawEventData={rawEventData}
|
||||
rawEventData={rawEventData as AlertRawEventData}
|
||||
showAlertDetails={showAlertDetails}
|
||||
timelineId={timelineId}
|
||||
isReadOnly={isReadOnly}
|
||||
|
@ -165,7 +166,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
isAlert={isAlert}
|
||||
isDraggable={isDraggable}
|
||||
loading={loading}
|
||||
rawEventData={rawEventData}
|
||||
rawEventData={rawEventData as AlertRawEventData}
|
||||
timelineId={timelineId}
|
||||
timelineTabType={tabType}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
|
|
|
@ -41,6 +41,7 @@ describe('schedule_notification_actions', () => {
|
|||
filters: [],
|
||||
index: ['index-123'],
|
||||
maxSignals: 100,
|
||||
responseActions: [],
|
||||
riskScore: 80,
|
||||
riskScoreMapping: [],
|
||||
ruleNameOverride: undefined,
|
||||
|
|
|
@ -42,6 +42,7 @@ describe('schedule_throttle_notification_actions', () => {
|
|||
filters: [],
|
||||
index: ['index-123'],
|
||||
maxSignals: 100,
|
||||
responseActions: [],
|
||||
riskScore: 80,
|
||||
riskScoreMapping: [],
|
||||
ruleNameOverride: undefined,
|
||||
|
|
|
@ -106,6 +106,7 @@ const createSecuritySolutionRequestContextMock = (
|
|||
): jest.Mocked<SecuritySolutionApiRequestHandlerContext> => {
|
||||
const core = clients.core;
|
||||
const kibanaRequest = requestMock.create();
|
||||
const licensing = licensingMock.createSetup();
|
||||
|
||||
return {
|
||||
core,
|
||||
|
@ -137,6 +138,10 @@ const createSecuritySolutionRequestContextMock = (
|
|||
// TODO: Mock EndpointScopedFleetServicesInterface and return the mocked object.
|
||||
throw new Error('Not implemented');
|
||||
}),
|
||||
getQueryRuleAdditionalOptions: {
|
||||
licensing,
|
||||
osqueryCreateAction: jest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -89,6 +89,7 @@ export const getOutputRuleAlertForRest = (): FullResponseSchema => ({
|
|||
execution_summary: undefined,
|
||||
related_integrations: [],
|
||||
required_fields: [],
|
||||
response_actions: undefined,
|
||||
setup: '',
|
||||
outcome: undefined,
|
||||
alias_target_id: undefined,
|
||||
|
|
|
@ -88,6 +88,8 @@ export const previewRulesRoute = async (
|
|||
const searchSourceClient = await data.search.searchSource.asScoped(request);
|
||||
const savedObjectsClient = coreContext.savedObjects.client;
|
||||
const siemClient = (await context.securitySolution).getAppClient();
|
||||
const { getQueryRuleAdditionalOptions: queryRuleAdditionalOptions } =
|
||||
await context.securitySolution;
|
||||
|
||||
const timeframeEnd = request.body.timeframeEnd;
|
||||
let invocationCount = request.body.invocationCount;
|
||||
|
@ -281,7 +283,9 @@ export const previewRulesRoute = async (
|
|||
|
||||
switch (previewRuleParams.type) {
|
||||
case 'query':
|
||||
const queryAlertType = previewRuleTypeWrapper(createQueryAlertType(ruleOptions));
|
||||
const queryAlertType = previewRuleTypeWrapper(
|
||||
createQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions })
|
||||
);
|
||||
await runExecutors(
|
||||
queryAlertType.executor,
|
||||
queryAlertType.id,
|
||||
|
@ -300,7 +304,7 @@ export const previewRulesRoute = async (
|
|||
break;
|
||||
case 'saved_query':
|
||||
const savedQueryAlertType = previewRuleTypeWrapper(
|
||||
createSavedQueryAlertType(ruleOptions)
|
||||
createSavedQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions })
|
||||
);
|
||||
await runExecutors(
|
||||
savedQueryAlertType.executor,
|
||||
|
|
|
@ -66,6 +66,7 @@ export const ruleOutput = (): FullResponseSchema => ({
|
|||
timeline_id: 'some-timeline-id',
|
||||
related_integrations: [],
|
||||
required_fields: [],
|
||||
response_actions: undefined,
|
||||
setup: '',
|
||||
outcome: undefined,
|
||||
alias_target_id: undefined,
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { map, uniq } from 'lodash';
|
||||
import type { RuleResponseAction } from '../../../../common/detection_engine/rule_response_actions/schemas';
|
||||
import { RESPONSE_ACTION_TYPES } from '../../../../common/detection_engine/rule_response_actions/schemas';
|
||||
import type { SetupPlugins } from '../../../plugin_contract';
|
||||
|
||||
interface OsqueryQuery {
|
||||
id: string;
|
||||
query: string;
|
||||
ecs_mapping: Record<string, Record<'field', string>>;
|
||||
version: string;
|
||||
interval?: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface OsqueryResponseAction {
|
||||
actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY;
|
||||
params: {
|
||||
id: string;
|
||||
queries: OsqueryQuery[];
|
||||
savedQueryId: string;
|
||||
query: string;
|
||||
packId: string;
|
||||
ecs_mapping?: Record<string, { field?: string; value?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScheduleNotificationActions {
|
||||
signals: unknown[];
|
||||
responseActions: RuleResponseAction[];
|
||||
}
|
||||
|
||||
interface IAlert {
|
||||
agent: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const isOsqueryAction = (action: RuleResponseAction): action is OsqueryResponseAction => {
|
||||
return action.actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY;
|
||||
};
|
||||
|
||||
export const scheduleNotificationResponseActions = (
|
||||
{ signals, responseActions }: ScheduleNotificationActions,
|
||||
osqueryCreateAction?: SetupPlugins['osquery']['osqueryCreateAction']
|
||||
) => {
|
||||
const filteredAlerts = (signals as IAlert[]).filter((alert) => alert.agent?.id);
|
||||
const agentIds = uniq(filteredAlerts.map((alert: IAlert) => alert.agent?.id));
|
||||
const alertIds = map(filteredAlerts, '_id');
|
||||
|
||||
responseActions.forEach((responseAction) => {
|
||||
if (isOsqueryAction(responseAction) && osqueryCreateAction) {
|
||||
const {
|
||||
savedQueryId,
|
||||
packId,
|
||||
queries,
|
||||
ecs_mapping: ecsMapping,
|
||||
...rest
|
||||
} = responseAction.params;
|
||||
|
||||
return osqueryCreateAction({
|
||||
...rest,
|
||||
queries,
|
||||
ecs_mapping: ecsMapping,
|
||||
saved_query_id: savedQueryId,
|
||||
agent_ids: agentIds,
|
||||
alert_ids: alertIds,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -16,6 +16,7 @@ import { createMockTelemetryEventsSender } from '../../../telemetry/__mocks__';
|
|||
import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
|
||||
import { sampleDocNoSortId } from '../../signals/__mocks__/es_results';
|
||||
import { getQueryRuleParams } from '../../schemas/rule_schemas.mock';
|
||||
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
|
||||
|
||||
jest.mock('../../signals/utils', () => ({
|
||||
...jest.requireActual('../../signals/utils'),
|
||||
|
@ -31,6 +32,8 @@ jest.mock('../utils/get_list_client', () => ({
|
|||
|
||||
describe('Custom Query Alerts', () => {
|
||||
const mocks = createRuleTypeMocks();
|
||||
const licensing = licensingMock.createSetup();
|
||||
|
||||
const { dependencies, executor, services } = mocks;
|
||||
const { alerting, lists, logger, ruleDataClient } = dependencies;
|
||||
const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({
|
||||
|
@ -51,6 +54,8 @@ describe('Custom Query Alerts', () => {
|
|||
const queryAlertType = securityRuleTypeWrapper(
|
||||
createQueryAlertType({
|
||||
eventsTelemetry,
|
||||
licensing,
|
||||
osqueryCreateAction: () => null,
|
||||
experimentalFeatures: allowedExperimentalValues,
|
||||
logger,
|
||||
version: '1.0.0',
|
||||
|
@ -95,6 +100,8 @@ describe('Custom Query Alerts', () => {
|
|||
const queryAlertType = securityRuleTypeWrapper(
|
||||
createQueryAlertType({
|
||||
eventsTelemetry,
|
||||
licensing,
|
||||
osqueryCreateAction: () => null,
|
||||
experimentalFeatures: allowedExperimentalValues,
|
||||
logger,
|
||||
version: '1.0.0',
|
||||
|
|
|
@ -12,12 +12,14 @@ import { SERVER_APP_ID } from '../../../../../common/constants';
|
|||
import type { UnifiedQueryRuleParams } from '../../schemas/rule_schemas';
|
||||
import { unifiedQueryRuleParams } from '../../schemas/rule_schemas';
|
||||
import { queryExecutor } from '../../signals/executors/query';
|
||||
import type { CreateRuleOptions, SecurityAlertType } from '../types';
|
||||
import type { CreateQueryRuleOptions, SecurityAlertType } from '../types';
|
||||
import { validateIndexPatterns } from '../utils';
|
||||
|
||||
export const createQueryAlertType = (
|
||||
createOptions: CreateRuleOptions
|
||||
createOptions: CreateQueryRuleOptions
|
||||
): SecurityAlertType<UnifiedQueryRuleParams, {}, {}, 'default'> => {
|
||||
const { eventsTelemetry, experimentalFeatures, version } = createOptions;
|
||||
const { eventsTelemetry, experimentalFeatures, version, osqueryCreateAction, licensing } =
|
||||
createOptions;
|
||||
return {
|
||||
id: QUERY_RULE_TYPE_ID,
|
||||
name: 'Custom Query Rule',
|
||||
|
@ -97,6 +99,8 @@ export const createQueryAlertType = (
|
|||
secondaryTimestamp,
|
||||
unprocessedExceptions,
|
||||
exceptionFilter,
|
||||
osqueryCreateAction,
|
||||
licensing,
|
||||
});
|
||||
return { ...result, state };
|
||||
},
|
||||
|
|
|
@ -12,12 +12,13 @@ import { SERVER_APP_ID } from '../../../../../common/constants';
|
|||
import type { CompleteRule, UnifiedQueryRuleParams } from '../../schemas/rule_schemas';
|
||||
import { unifiedQueryRuleParams } from '../../schemas/rule_schemas';
|
||||
import { queryExecutor } from '../../signals/executors/query';
|
||||
import type { CreateRuleOptions, SecurityAlertType } from '../types';
|
||||
import type { CreateQueryRuleOptions, SecurityAlertType } from '../types';
|
||||
import { validateIndexPatterns } from '../utils';
|
||||
|
||||
export const createSavedQueryAlertType = (
|
||||
createOptions: CreateRuleOptions
|
||||
createOptions: CreateQueryRuleOptions
|
||||
): SecurityAlertType<UnifiedQueryRuleParams, {}, {}, 'default'> => {
|
||||
const { experimentalFeatures, version } = createOptions;
|
||||
const { experimentalFeatures, version, osqueryCreateAction, licensing } = createOptions;
|
||||
return {
|
||||
id: SAVED_QUERY_RULE_TYPE_ID,
|
||||
name: 'Saved Query Rule',
|
||||
|
@ -98,6 +99,8 @@ export const createSavedQueryAlertType = (
|
|||
secondaryTimestamp,
|
||||
exceptionFilter,
|
||||
unprocessedExceptions,
|
||||
osqueryCreateAction,
|
||||
licensing,
|
||||
});
|
||||
return { ...result, state };
|
||||
},
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue