[Osquery] Add Detection action (#133279)

Adds Rule response action with osquery
This commit is contained in:
Tomasz Ciecierski 2022-09-20 08:24:01 +02:00 committed by GitHub
parent 208d1bf32f
commit fe6060d22a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 2699 additions and 481 deletions

3
.github/CODEOWNERS vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -52,6 +52,11 @@ const ActionsTableComponent = () => {
limit: pageSize,
direction: Direction.desc,
sortField: '@timestamp',
filterQuery: {
exists: {
field: 'user_id',
},
},
});
const onTableChange = useCallback(({ page = {} }) => {

View file

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

View file

@ -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 = ({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[];

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { 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>
</>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { 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>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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}</>;

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type React from 'react';
export interface OsqueryActionResultsProps {
agentIds?: string[];
ruleName?: string[];
alertId: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
}

View file

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

View file

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

View file

@ -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": ["*"]
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './create_action_handler';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './action';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './response_actions';

View file

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

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * 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>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -53,6 +53,9 @@ export const useKibana = jest.fn().mockReturnValue({
},
},
},
osquery: {
OsqueryResults: jest.fn().mockReturnValue(null),
},
timelines: createTGridMocks(),
savedObjectsTagging: {
ui: {

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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.',
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useKibana } from '../../common/lib/kibana';
export const useOsqueryEnabled = () => {
const { osquery } = useKibana().services;
const osqueryStatus = osquery?.fetchInstallationStatus();
return !osqueryStatus?.loading && !osqueryStatus?.disabled && !osqueryStatus?.permissionDenied;
};

View file

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

View file

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

View file

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

View file

@ -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: [],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ export type {
FormSchema,
ValidationError,
ValidationFunc,
ArrayItem,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
export {
getUseField,

View file

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

View file

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

View file

@ -41,6 +41,7 @@ describe('schedule_notification_actions', () => {
filters: [],
index: ['index-123'],
maxSignals: 100,
responseActions: [],
riskScore: 80,
riskScoreMapping: [],
ruleNameOverride: undefined,

View file

@ -42,6 +42,7 @@ describe('schedule_throttle_notification_actions', () => {
filters: [],
index: ['index-123'],
maxSignals: 100,
responseActions: [],
riskScore: 80,
riskScoreMapping: [],
ruleNameOverride: undefined,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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