[Osquery] Substitute Event Data in place of {{parameter}} in Osquery run from Alerts (#146598)

This commit is contained in:
Tomasz Ciecierski 2023-01-05 15:49:40 +01:00 committed by GitHub
parent ee3869b0cd
commit ed0e6b2acb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1401 additions and 595 deletions

View file

@ -99,6 +99,8 @@ export const arrayQueries = t.array(
ecs_mapping: ecsMappingOrUndefined,
version: versionOrUndefined,
platform: platformOrUndefined,
removed: removedOrUndefined,
snapshot: snapshotOrUndefined,
})
);
export type ArrayQueries = t.TypeOf<typeof arrayQueries>;
@ -111,10 +113,12 @@ export const objectQueries = t.record(
version: versionOrUndefined,
platform: platformOrUndefined,
saved_query_id: savedQueryIdOrUndefined,
removed: removedOrUndefined,
snapshot: snapshotOrUndefined,
})
);
export type ObjectQueries = t.TypeOf<typeof objectQueries>;
export const queries = t.union([arrayQueries, objectQueries]);
export type Queries = t.TypeOf<typeof queries>;
export const queriesOrUndefined = t.union([queries, t.undefined]);
export const queriesOrUndefined = t.union([arrayQueries, t.undefined]); // in the future we might need to support `objectQueries` so use `queries` instead of `arrayQueries` - now removing this because of strange type issue where query is a number
export type QueriesOrUndefined = t.TypeOf<typeof queriesOrUndefined>;

View file

@ -10,7 +10,7 @@ export interface CodeSignature {
trusted: string[];
}
export interface Ext {
code_signature: CodeSignature[] | CodeSignature;
code_signature?: CodeSignature[] | CodeSignature;
}
export interface Hash {
sha256: string[];

View file

@ -9,7 +9,7 @@ export interface RuleEcs {
id?: string[];
rule_id?: string[];
name?: string[];
false_positives: string[];
false_positives?: string[];
saved_id?: string[];
timeline_id?: string[];
timeline_title?: string[];
@ -27,10 +27,7 @@ export interface RuleEcs {
severity?: string[];
tags?: string[];
threat?: unknown;
threshold?: {
field: string | string[];
value: number;
};
threshold?: unknown;
type?: string[];
size?: string[];
to?: string[];

View file

@ -12,7 +12,7 @@ import {
savedQueryIdOrUndefined,
packIdOrUndefined,
queryOrUndefined,
queriesOrUndefined,
arrayQueries,
} from '@kbn/osquery-io-ts-types';
export const createLiveQueryRequestBodySchema = t.partial({
@ -21,7 +21,7 @@ export const createLiveQueryRequestBodySchema = t.partial({
agent_platforms: t.array(t.string),
agent_policy_ids: t.array(t.string),
query: queryOrUndefined,
queries: queriesOrUndefined,
queries: arrayQueries,
saved_query_id: savedQueryIdOrUndefined,
ecs_mapping: ecsMappingOrUndefined,
pack_id: packIdOrUndefined,

View file

@ -0,0 +1,85 @@
/*
* 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 { replaceParamsQuery } from './replace_params_query';
describe('replaceParamsQuery', () => {
it('should return unchanged query, and skipped true', () => {
const query = 'SELECT * FROM processes WHERE version = {{params.version}}';
const { result, skipped } = replaceParamsQuery(query, {});
expect(result).toBe(query);
expect(skipped).toBe(true);
});
it('should return proper value instead of params if field is found', () => {
const query = 'SELECT * FROM processes WHERE version = {{kibana.version}}';
const { result, skipped } = replaceParamsQuery(query, { kibana: { version: '8.7.0' } });
const expectedQuery = 'SELECT * FROM processes WHERE version = 8.7.0';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(false);
});
it('should return proper value instead of params with multiple params', () => {
const query =
'SELECT * FROM processes WHERE version = {{kibana.version}} and pid = {{kibana.pid}}';
const { result, skipped } = replaceParamsQuery(query, {
kibana: { version: '8.7.0', pid: '123' },
});
const expectedQuery = 'SELECT * FROM processes WHERE version = 8.7.0 and pid = 123';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(false);
});
it('should return proper value if param has white spaces inside', () => {
const query = 'SELECT * FROM processes WHERE version = {{ kibana.version }}';
const { result, skipped } = replaceParamsQuery(query, { kibana: { version: '8.7.0' } });
const expectedQuery = 'SELECT * FROM processes WHERE version = 8.7.0';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(false);
});
it('should not change query if there are no opening curly braces but still skipped false', () => {
const query = 'SELECT * FROM processes WHERE version = kibana.version }}';
const { result, skipped } = replaceParamsQuery(query, { kibana: { version: '8.7.0' } });
const expectedQuery = 'SELECT * FROM processes WHERE version = kibana.version }}';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(false);
});
it('should return skipped true if {{params}} field not found', () => {
const query =
'SELECT * FROM processes WHERE version = {{kibana.version}} {{not.existing}} {{agent.name}}';
const { result, skipped } = replaceParamsQuery(query, {
kibana: { version: '8.7.0' },
agent: { name: 'testAgent' },
});
const expectedQuery =
'SELECT * FROM processes WHERE version = 8.7.0 {{not.existing}} testAgent';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(true);
});
it('should return replaced values even if params are duplicated, but also return skip true', () => {
const query =
'SELECT * FROM processes WHERE version = {{ kibana.version}} {{not.existing }} {{kibana.version}} {{kibana.version}} {{agent.name}}';
const { result, skipped } = replaceParamsQuery(query, {
kibana: { version: '8.7.0' },
agent: { name: 'testAgent' },
});
const expectedQuery =
'SELECT * FROM processes WHERE version = 8.7.0 {{not.existing }} 8.7.0 8.7.0 testAgent';
expect(result).toBe(expectedQuery);
expect(skipped).toBe(true);
});
it('handle complex windows query with registry as param', () => {
// eslint-disable-next-line no-useless-escape
const query = `select * FROM registry WHERE key LIKE 'HKEY_USERS\{{user.id}}\Software\Microsoft\IdentityCRL\Immersive\production\Token\{0CB4A94A-6E8C-477B-88C8-A3799FC97414}'`;
const { result, skipped } = replaceParamsQuery(query, {
user: { id: 'S-1-5-20' },
});
// eslint-disable-next-line no-useless-escape
const expectedQuery = `select * FROM registry WHERE key LIKE 'HKEY_USERS\S-1-5-20\Software\Microsoft\IdentityCRL\Immersive\production\Token\{0CB4A94A-6E8C-477B-88C8-A3799FC97414}'`;
expect(result).toBe(expectedQuery);
expect(skipped).toBe(false);
});
});

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 { each, get } from 'lodash';
export const replaceParamsQuery = (query: string, data: object) => {
const regex = /\{{([^}]+)\}}/g; // when there are 2 opening and 2 closing curly brackets (including brackets)
const matchedBrackets = query.match(regex);
let resultQuery = query;
if (matchedBrackets) {
each(matchedBrackets, (bracesText: string) => {
const field = bracesText.replace(/{{|}}/g, '').trim();
if (resultQuery.includes(bracesText)) {
const foundFieldValue = get(data, field);
if (foundFieldValue) {
resultQuery = resultQuery.replace(bracesText, foundFieldValue);
}
}
});
}
const skipped = regex.test(resultQuery);
return {
result: resultQuery,
skipped,
};
};

View file

@ -10,6 +10,7 @@ import {
RESPONSE_ACTIONS_ITEM_1,
RESPONSE_ACTIONS_ITEM_2,
OSQUERY_RESPONSE_ACTION_ADD_BUTTON,
RESPONSE_ACTIONS_ITEM_3,
} from '../../tasks/response_actions';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { login } from '../../tasks/login';
@ -196,6 +197,40 @@ describe('Alert Event Details', () => {
});
});
it('should be able to add investigation guides to response actions', () => {
const investigationGuideNote =
'It seems that you have suggested queries in investigation guide, would you like to add them as response actions?';
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.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => {
cy.contains('Example');
});
cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => {
cy.contains('select * from uptime');
});
cy.getBySel(RESPONSE_ACTIONS_ITEM_2).should('not.exist');
cy.getBySel(RESPONSE_ACTIONS_ITEM_3).should('not.exist');
cy.contains(investigationGuideNote);
cy.getBySel('osqueryAddInvestigationGuideQueries').click();
cy.contains(investigationGuideNote).should('not.exist');
cy.getBySel(RESPONSE_ACTIONS_ITEM_0).within(() => {
cy.contains('Example');
});
cy.getBySel(RESPONSE_ACTIONS_ITEM_1).within(() => {
cy.contains('select * from uptime');
});
cy.getBySel(RESPONSE_ACTIONS_ITEM_2).within(() => {
cy.contains('SELECT * FROM processes;');
});
cy.getBySel(RESPONSE_ACTIONS_ITEM_3).within(() => {
cy.contains('select * from users');
});
});
it('should be able to run live query and add to timeline (-depending on the previous test)', () => {
const TIMELINE_NAME = 'Untitled timeline';
cy.visit('/app/security/alerts');

View file

@ -8,5 +8,6 @@
export const RESPONSE_ACTIONS_ITEM_0 = 'response-actions-list-item-0';
export const RESPONSE_ACTIONS_ITEM_1 = 'response-actions-list-item-1';
export const RESPONSE_ACTIONS_ITEM_2 = 'response-actions-list-item-2';
export const RESPONSE_ACTIONS_ITEM_3 = 'response-actions-list-item-3';
export const OSQUERY_RESPONSE_ACTION_ADD_BUTTON = 'osquery-response-action-type-selection-option';

View file

@ -18,6 +18,7 @@ interface ActionResultsSummaryProps {
actionId: string;
expirationDate?: string;
agentIds?: string[];
error?: string;
}
const renderErrorMessage = (error: string) => (
@ -30,6 +31,7 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
actionId,
expirationDate,
agentIds,
error,
}) => {
const [pageIndex] = useState(0);
const [pageSize] = useState(50);
@ -52,22 +54,42 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
isLive,
skip: !hasActionResultsPrivileges,
});
if (expired) {
edges.forEach((edge) => {
if (!edge.fields?.completed_at && edge.fields) {
edge.fields['error.keyword'] = edge.fields.error = [
i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredErrorText', {
defaultMessage: 'The action request timed out.',
}),
];
}
});
}
useEffect(() => {
if (error) {
edges.forEach((edge) => {
if (edge.fields) {
edge.fields['error.skipped'] = edge.fields.error = [
i18n.translate('xpack.osquery.liveQueryActionResults.table.skippedErrorText', {
defaultMessage:
"This query hasn't been called due to parameter used and its value not found in the alert.",
}),
];
}
});
} else if (expired) {
edges.forEach((edge) => {
if (!edge.fields?.completed_at && edge.fields) {
edge.fields['error.keyword'] = edge.fields.error = [
i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredErrorText', {
defaultMessage: 'The action request timed out.',
}),
];
}
});
}
}, [edges, error, expired]);
const renderAgentIdColumn = useCallback((agentId) => <AgentIdToName agentId={agentId} />, []);
const renderRowsColumn = useCallback((rowsCount) => rowsCount ?? '-', []);
const renderStatusColumn = useCallback(
(_, item) => {
if (item.fields['error.skipped']) {
return i18n.translate('xpack.osquery.liveQueryActionResults.table.skippedStatusText', {
defaultMessage: 'skipped',
});
}
if (!item.fields.completed_at) {
return expired
? i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredStatusText', {
@ -139,11 +161,11 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
useEffect(() => {
setIsLive(() => {
if (!agentIds?.length || expired) return false;
if (!agentIds?.length || expired || error) return false;
return !!(aggregations.totalResponded !== agentIds?.length);
});
}, [agentIds?.length, aggregations.totalResponded, expired]);
}, [agentIds?.length, aggregations.totalResponded, error, expired]);
return edges.length ? (
<EuiInMemoryTable loading={isLive} items={edges} columns={columns} pagination={pagination} />

View file

@ -10,33 +10,42 @@ import { useQuery } from '@tanstack/react-query';
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 { ActionEdges, ActionsStrategyResponse } from '../../common/search_strategy';
import type { ESTermQuery, ESExistsQuery } from '../../common/typed_json';
import { useErrorToast } from '../common/hooks/use_error_toast';
import { Direction } from '../../common/search_strategy';
interface UseAllLiveQueries {
export interface UseAllLiveQueriesConfig {
activePage: number;
direction: Direction;
direction?: Direction;
limit: number;
sortField: string;
filterQuery?: ESTermQuery | ESExistsQuery | string;
skip?: boolean;
alertId?: string;
}
// Make sure we keep this and ACTIONS_QUERY_KEY in osquery_flyout.tsx in sync.
const ACTIONS_QUERY_KEY = 'actions';
export const useAllLiveQueries = ({
activePage,
direction,
direction = Direction.desc,
limit,
sortField,
filterQuery,
skip = false,
}: UseAllLiveQueries) => {
alertId,
}: UseAllLiveQueriesConfig) => {
const { http } = useKibana().services;
const setErrorToast = useErrorToast();
return useQuery(
['actions', { activePage, direction, limit, sortField }],
[
ACTIONS_QUERY_KEY,
{ activePage, direction, limit, sortField, ...(alertId ? { alertId } : {}) },
],
() =>
http.get<{ data: Omit<ActionsStrategyResponse, 'edges'> & { items: ActionEdges } }>(
'/api/osquery/live_queries',

View file

@ -6,10 +6,6 @@
*/
import React from 'react';
import type { Ecs } from '../../common/ecs';
export interface AlertEcsData {
_id: string;
_index?: string;
}
export const AlertAttachmentContext = React.createContext<AlertEcsData | null>(null);
export const AlertAttachmentContext = React.createContext<Ecs | null>(null);

View file

@ -86,7 +86,7 @@ const DocsColumnResults: React.FC<DocsColumnResultsProps> = ({ count, isLive })
<EuiFlexItem grow={false}>
{count ? <EuiNotificationBadge color="subdued">{count}</EuiNotificationBadge> : '-'}
</EuiFlexItem>
{isLive ? (
{!isLive ? (
<EuiFlexItem grow={false} data-test-subj={'live-query-loading'}>
<EuiLoadingSpinner />
</EuiFlexItem>
@ -130,6 +130,7 @@ type PackQueryStatusItem = Partial<{
status?: string;
pending?: number;
docs?: number;
error?: string;
}>;
interface PackQueriesStatusTableProps {
@ -192,15 +193,12 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
[handleQueryFlyoutOpen]
);
const renderDocsColumn = useCallback(
(item: PackQueryStatusItem) => (
<DocsColumnResults
count={item?.docs ?? 0}
isLive={item?.status === 'running' && item?.pending !== 0}
/>
),
[]
);
const renderDocsColumn = useCallback((item: PackQueryStatusItem) => {
const isLive =
!item?.status || !!item.error || (item?.status !== 'running' && item?.pending === 0);
return <DocsColumnResults count={item?.docs ?? 0} isLive={isLive} />;
}, []);
const renderAgentsColumn = useCallback((item) => {
if (!item.action_id) return;
@ -239,6 +237,7 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
endDate={expirationDate}
agentIds={agentIds}
failedAgentsCount={item?.failed ?? 0}
error={item.error}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -12,22 +12,29 @@ import styled from 'styled-components';
import { useController } from 'react-hook-form';
const StyledEuiCard = styled(EuiCard)`
padding: 16px 92px 16px 16px !important;
padding: 0;
display: flex;
flex-direction: row;
border: ${(props) => {
if (props.selectable?.isSelected) {
return `1px solid ${props.theme.eui.euiColorSuccess}`;
}
}};
.euiCard__content {
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;
min-inline-size: 80px;
height: 100% !important;
width: 80px;
right: 0;
border-radius: 0 5px 5px 0;
> span {
@ -43,12 +50,7 @@ const StyledEuiCard = styled(EuiCard)`
}
}
}
button[aria-checked='false'] > span > svg {
display: none;
}
`;
interface QueryPackSelectableProps {
canRunSingleQuery: boolean;
canRunPacks: boolean;
@ -79,6 +81,7 @@ export const QueryPackSelectable = ({
onClick: () => handleChange('query'),
isSelected: queryType === 'query',
iconType: 'check',
textProps: {}, // this is needed for the text to get wrapped in span
}),
[queryType, handleChange]
);
@ -88,6 +91,7 @@ export const QueryPackSelectable = ({
onClick: () => handleChange('pack'),
isSelected: queryType === 'pack',
iconType: 'check',
textProps: {}, // this is needed for the text to get wrapped in span
}),
[queryType, handleChange]
);

View file

@ -7,10 +7,12 @@
import { castArray, isEmpty, pickBy } from 'lodash';
import { EuiCode, EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui';
import React, { useMemo } from 'react';
import React, { useContext, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ECSMapping } from '@kbn/osquery-io-ts-types';
import { replaceParamsQuery } from '../../common/utils/replace_params_query';
import { AlertAttachmentContext } from '../common/contexts';
import { LiveQueryForm } from './form';
import { useActionResultsPrivileges } from '../action_results/use_action_privileges';
import { OSQUERY_INTEGRATION_NAME } from '../../common';
@ -72,19 +74,30 @@ const LiveQueryComponent: React.FC<LiveQueryProps> = ({
return null;
}, [agentId, agentIds, agentPolicyIds, agentSelection]);
const ecsData = useContext(AlertAttachmentContext);
const initialQuery = useMemo(() => {
if (ecsData && query) {
const { result } = replaceParamsQuery(query, ecsData);
return result;
}
return query;
}, [ecsData, query]);
const defaultValue = useMemo(() => {
const initialValue = {
...(initialAgentSelection ? { agentSelection: initialAgentSelection } : {}),
alertIds,
query,
query: initialQuery,
savedQueryId,
ecs_mapping,
packId,
};
return !isEmpty(pickBy(initialValue, (value) => !isEmpty(value))) ? initialValue : undefined;
}, [alertIds, ecs_mapping, initialAgentSelection, packId, query, savedQueryId]);
}, [alertIds, ecs_mapping, initialAgentSelection, initialQuery, packId, savedQueryId]);
if (isLoading) {
return <EuiLoadingContent lines={10} />;

View file

@ -23,6 +23,10 @@ const StyledEuiCard = styled(EuiCard)`
font-size: 1rem;
}
.euiSpacer {
display: none;
}
.euiText {
margin-top: 0;
margin-left: 25px;

View file

@ -14,6 +14,7 @@ import type {
} from '@kbn/core/public';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { useAllLiveQueries } from './actions/use_all_live_queries';
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';
@ -128,6 +129,7 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
kibanaVersion: this.kibanaVersion,
}),
OsqueryResponseActionTypeForm: getLazyOsqueryResponseActionTypeForm(),
fetchAllLiveQueries: useAllLiveQueries,
fetchInstallationStatus: useFetchStatus,
isOsqueryAvailable: useIsOsqueryAvailableSimple,
};

View file

@ -61,6 +61,7 @@ export interface ResultsTableComponentProps {
endDate?: string;
startDate?: string;
liveQueryActionId?: string;
error?: string;
}
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
@ -70,6 +71,7 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
startDate,
endDate,
liveQueryActionId,
error,
}) => {
const [isLive, setIsLive] = useState(true);
const { data: hasActionResultsPrivileges } = useActionResultsPrivileges();
@ -367,7 +369,7 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
useEffect(
() =>
setIsLive(() => {
if (!agentIds?.length || expired) return false;
if (!agentIds?.length || expired || error) return false;
return !!(
aggregations.totalResponded !== agentIds?.length ||
@ -381,6 +383,7 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
aggregations?.totalRowCount,
allResultsData?.edges.length,
allResultsData?.total,
error,
expired,
]
);

View file

@ -27,6 +27,7 @@ interface ResultTabsProps {
failedAgentsCount?: number;
endDate?: string;
liveQueryActionId?: string;
error?: string;
}
const ResultTabsComponent: React.FC<ResultTabsProps> = ({
@ -37,6 +38,7 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
failedAgentsCount,
startDate,
liveQueryActionId,
error,
}) => {
const tabs = useMemo(
() => [
@ -52,6 +54,7 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
startDate={startDate}
endDate={endDate}
liveQueryActionId={liveQueryActionId}
error={error}
/>
),
},
@ -60,7 +63,12 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
name: 'Status',
'data-test-subj': 'osquery-status-tab',
content: (
<ActionResultsSummary actionId={actionId} agentIds={agentIds} expirationDate={endDate} />
<ActionResultsSummary
actionId={actionId}
agentIds={agentIds}
expirationDate={endDate}
error={error}
/>
),
append: failedAgentsCount ? (
<EuiNotificationBadge className="eui-alignCenter" size="m">
@ -69,7 +77,16 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
) : null,
},
],
[actionId, agentIds, ecsMapping, startDate, endDate, liveQueryActionId, failedAgentsCount]
[
actionId,
agentIds,
ecsMapping,
startDate,
endDate,
liveQueryActionId,
error,
failedAgentsCount,
]
);
return (

View file

@ -12,6 +12,8 @@ import type { LiveQueryQueryFieldProps } from '../live_queries/form/live_query_q
import type { ServicesWrapperProps } from './services_wrapper';
import ServicesWrapper from './services_wrapper';
const LiveQueryField = lazy(() => import('../live_queries/form/live_query_query_field'));
export const getLazyLiveQueryField =
(services: ServicesWrapperProps['services']) =>
// eslint-disable-next-line react/display-name
@ -24,10 +26,8 @@ export const getLazyLiveQueryField =
query: string;
ecs_mapping: Record<string, unknown>;
}>;
}) => {
const LiveQueryField = lazy(() => import('../live_queries/form/live_query_query_field'));
return (
}) =>
(
<Suspense fallback={null}>
<ServicesWrapper services={services}>
<FormProvider {...formMethods}>
@ -36,4 +36,3 @@ export const getLazyLiveQueryField =
</ServicesWrapper>
</Suspense>
);
};

View file

@ -6,18 +6,17 @@
*/
import React, { lazy, Suspense, useMemo } from 'react';
import type { Ecs } from '../../common/ecs';
import ServicesWrapper from './services_wrapper';
import type { ServicesWrapperProps } from './services_wrapper';
import type { OsqueryActionProps } from './osquery_action';
import type { AlertEcsData } from '../common/contexts';
import { AlertAttachmentContext } from '../common/contexts';
const OsqueryAction = lazy(() => import('./osquery_action'));
export const getLazyOsqueryAction =
(services: ServicesWrapperProps['services']) =>
// eslint-disable-next-line react/display-name
(props: OsqueryActionProps & { ecsData?: AlertEcsData }) => {
const OsqueryAction = lazy(() => import('./osquery_action'));
(props: OsqueryActionProps & { ecsData?: Ecs }) => {
const { ecsData, ...restProps } = props;
const renderAction = useMemo(() => {
if (ecsData && ecsData?._id) {
@ -29,7 +28,7 @@ export const getLazyOsqueryAction =
}
return <OsqueryAction {...restProps} />;
}, [OsqueryAction, ecsData, restProps]);
}, [ecsData, restProps]);
return (
<Suspense fallback={null}>

View file

@ -14,14 +14,13 @@ interface BigServices extends StartServices {
storage: unknown;
}
const OsqueryResults = lazy(() => import('./osquery_results'));
export const getLazyOsqueryResults =
// eslint-disable-next-line react/display-name
(services: BigServices) => (props: OsqueryActionResultsProps) => {
const OsqueryResults = lazy(() => import('./osquery_results'));
return (
(services: BigServices) => (props: OsqueryActionResultsProps) =>
(
<Suspense fallback={null}>
<OsqueryResults services={services} {...props} />
</Suspense>
);
};

View file

@ -21,6 +21,7 @@ export interface OsqueryActionProps {
defaultValues?: {};
formType: 'steps' | 'simple';
hideAgentsField?: boolean;
onSuccess?: () => void;
}
const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
@ -28,6 +29,7 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
formType = 'simple',
defaultValues,
hideAgentsField,
onSuccess,
}) => {
const permissions = useKibana().services.application.capabilities.osquery;
@ -91,6 +93,7 @@ const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({
formType={formType}
agentId={agentId}
hideAgentsField={hideAgentsField}
onSuccess={onSuccess}
{...defaultValues}
/>
);

View file

@ -7,7 +7,6 @@
import React, { useEffect, useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import uuid from 'uuid';
import type { FieldErrors } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import { useForm as useHookForm, FormProvider } from 'react-hook-form';
@ -35,7 +34,6 @@ interface OsqueryResponseActionsValues {
interface OsqueryResponseActionsParamsFormFields {
savedQueryId: string | null;
id: string;
ecs_mapping: ECSMapping;
query: string;
packId?: string[];
@ -58,7 +56,6 @@ const OsqueryResponseActionParamsFormComponent = ({
onError,
onChange,
}: OsqueryResponseActionsParamsFormProps) => {
const uniqueId = useMemo(() => uuid.v4(), []);
const hooksForm = useHookForm<OsqueryResponseActionsParamsFormFields>({
mode: 'all',
defaultValues: defaultValues
@ -70,14 +67,13 @@ const OsqueryResponseActionParamsFormComponent = ({
}
: {
ecs_mapping: {},
id: uniqueId,
queryType: 'query',
},
});
const { watch, register, formState, control } = hooksForm;
const [packId, queryType, queries, id] = watch(['packId', 'queryType', 'queries', 'id']);
const [packId, queryType, queries] = watch(['packId', 'queryType', 'queries']);
const { data: packData } = usePack({
packId: packId?.[0],
skip: !packId?.[0],
@ -105,7 +101,6 @@ const OsqueryResponseActionParamsFormComponent = ({
useEffect(() => {
register('savedQueryId');
register('id');
}, [register]);
useEffect(() => {
@ -114,12 +109,10 @@ const OsqueryResponseActionParamsFormComponent = ({
// @ts-expect-error update types
formData.queryType === 'pack'
? {
id: formData.id,
packId: formData?.packId?.length ? formData?.packId[0] : undefined,
queries: formData.queries,
}
: {
id: formData.id,
savedQueryId: formData.savedQueryId,
query: formData.query,
ecsMapping: formData.ecs_mapping,
@ -150,10 +143,9 @@ const OsqueryResponseActionParamsFormComponent = ({
const queryDetails = useMemo(
() => ({
queries,
action_id: id,
agents: [],
}),
[id, queries]
[queries]
);
return (

View file

@ -10,9 +10,7 @@ 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';
@ -23,41 +21,30 @@ import { OsqueryResult } from './osquery_result';
const OsqueryActionResultsComponent: React.FC<OsqueryActionResultsProps> = ({
agentIds,
ruleName,
alertId,
actionItems,
ecsData,
}) => {
const { data: actionsData } = useAllLiveQueries({
filterQuery: { term: { alert_ids: alertId } },
activePage: 0,
limit: 100,
direction: Direction.desc,
sortField: '@timestamp',
});
}) => (
<div data-test-subj={'osquery-results'}>
{actionItems?.map((item) => {
const actionId = item.fields?.action_id?.[0];
const queryId = item.fields?.['queries.action_id']?.[0];
const startDate = item.fields?.['@timestamp'][0];
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}
agentIds={agentIds}
ecsData={ecsData}
/>
);
})}
<EuiSpacer size="s" />
</div>
);
};
return (
<OsqueryResult
key={actionId}
actionId={actionId}
queryId={queryId}
startDate={startDate}
ruleName={ruleName}
agentIds={agentIds}
ecsData={ecsData}
/>
);
})}
<EuiSpacer size="s" />
</div>
);
export const OsqueryActionResults = React.memo(OsqueryActionResultsComponent);

View file

@ -15,7 +15,7 @@ import { ATTACHED_QUERY } from '../../agents/translations';
import { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table';
import { AlertAttachmentContext } from '../../common/contexts';
interface OsqueryResultProps extends Omit<OsqueryActionResultsProps, 'alertId'> {
interface OsqueryResultProps extends OsqueryActionResultsProps {
actionId: string;
queryId: string;
startDate: string;

View file

@ -13,11 +13,11 @@ 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, getMockedKibanaConfig } from './test_utils';
import type { OsqueryActionResultsProps } from './types';
jest.mock('../../common/lib/kibana');
@ -31,11 +31,21 @@ const enablePrivileges = () => {
}));
};
const defaultProps = {
const defaultProps: OsqueryActionResultsProps = {
agentIds: ['agent1'],
ruleName: ['Test-rule'],
ruleActions: [{ action_type_id: 'action1' }, { action_type_id: 'action2' }],
alertId: 'test-alert-id',
actionItems: [
{
_id: 'test',
_index: 'test',
fields: {
action_id: ['testActionId'],
'queries.action_id': ['queriesActionId'],
'queries.query': [DETAILS_QUERY],
'@timestamp': ['2022-09-08T18:16:30.256Z'],
},
},
],
ecsData: {
_id: 'test',
},
@ -66,23 +76,6 @@ const renderWithContext = (Element: React.ReactElement) =>
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')

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import type { AlertEcsData } from '../../common/contexts';
import type { Ecs } from '../../../common/ecs';
import type { ActionEdges } from '../../../common/search_strategy';
export interface OsqueryActionResultsProps {
agentIds?: string[];
ruleName?: string[];
alertId: string;
ecsData: AlertEcsData;
ecsData: Ecs;
actionItems?: ActionEdges;
}

View file

@ -25,6 +25,7 @@ import type {
getLazyOsqueryAction,
getLazyOsqueryResponseActionTypeForm,
} from './shared_components';
import type { useAllLiveQueries, UseAllLiveQueriesConfig } from './actions/use_all_live_queries';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface OsqueryPluginSetup {}
@ -36,6 +37,7 @@ export interface OsqueryPluginStart {
isOsqueryAvailable: (props: { agentId: string }) => boolean;
fetchInstallationStatus: () => { loading: boolean; disabled: boolean; permissionDenied: boolean };
OsqueryResponseActionTypeForm: ReturnType<typeof getLazyOsqueryResponseActionTypeForm>;
fetchAllLiveQueries: (config: UseAllLiveQueriesConfig) => ReturnType<typeof useAllLiveQueries>;
}
export interface AppPluginStartDependencies {

View file

@ -7,16 +7,16 @@
import uuid from 'uuid';
import moment from 'moment';
import { flatten, isEmpty, map, omit, pick, pickBy, some } from 'lodash';
import { filter, flatten, isEmpty, map, omit, pick, pickBy, some } from 'lodash';
import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import type { Ecs, SavedObjectsClientContract } from '@kbn/core/server';
import { createDynamicQueries, createQueries } from './create_queries';
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';
@ -25,17 +25,23 @@ interface Metadata {
currentUser: string | undefined;
}
interface CreateActionHandlerOptions {
soClient?: SavedObjectsClientContract;
metadata?: Metadata;
ecsData?: Ecs;
}
export const createActionHandler = async (
osqueryContext: OsqueryAppContext,
params: CreateLiveQueryRequestBodySchema,
soClient?: SavedObjectsClientContract,
metadata?: Metadata
options: CreateActionHandlerOptions
) => {
const [coreStartServices] = await osqueryContext.getStartServices();
const esClientInternal = coreStartServices.elasticsearch.client.asInternalUser;
const internalSavedObjectsClient = await getInternalSavedObjectsClient(
osqueryContext.getStartServices
);
const { soClient, metadata, ecsData } = options;
const savedObjectsClient = soClient ?? coreStartServices.savedObjects.createInternalRepository();
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -46,6 +52,7 @@ export const createActionHandler = async (
platformsSelected: agent_platforms,
policiesSelected: agent_policy_ids,
});
if (!selectedAgents.length) {
throw new Error('No agents found for selection');
}
@ -95,49 +102,24 @@ export const createActionHandler = async (
(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)
),
],
: ecsData
? await createDynamicQueries(params, ecsData, osqueryContext)
: await createQueries(params, selectedAgents, osqueryContext),
};
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']),
}));
const fleetActions = map(
filter(osqueryAction.queries, (query) => !query.error),
(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',

View file

@ -0,0 +1,118 @@
/*
* 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 { isEmpty, map, pickBy } from 'lodash';
import uuid from 'uuid';
import type { Ecs } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import type { OsqueryAppContext } from '../../lib/osquery_app_context_services';
import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
import { replaceParamsQuery } from '../../../common/utils/replace_params_query';
import { isSavedQueryPrebuilt } from '../../routes/saved_query/utils';
export const createQueries = async (
params: CreateLiveQueryRequestBodySchema,
agents: string[],
osqueryContext: OsqueryAppContext
) =>
params.queries?.length
? map(params.queries, (query) =>
pickBy(
{
...query,
action_id: uuid.v4(),
agents,
},
(value) => !isEmpty(value) || value === true
)
)
: [
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,
},
(value) => !isEmpty(value)
),
];
export const createDynamicQueries = async (
params: CreateLiveQueryRequestBodySchema,
alert: Ecs,
osqueryContext: OsqueryAppContext
) =>
params.queries?.length
? map(params.queries, ({ query, ...restQuery }) => {
const replacedQuery = replacedQueries(query, alert);
return pickBy(
{
...replacedQuery,
...restQuery,
action_id: uuid.v4(),
alert_ids: params.alert_ids,
agents: params.agent_ids,
},
(value) => !isEmpty(value) || value === true
);
})
: [
pickBy(
{
action_id: uuid.v4(),
id: uuid.v4(),
...replacedQueries(params.query, alert),
// just for single queries - we need to overwrite the error property
error: undefined,
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,
alert_ids: params.alert_ids,
agents: params.agent_ids,
},
(value) => !isEmpty(value)
),
];
const replacedQueries = (
query: string | undefined,
ecsData?: Ecs
): { query: string | undefined; error?: string } => {
if (ecsData && query) {
const { result, skipped } = replaceParamsQuery(query, ecsData);
return {
query: result,
...(skipped
? {
error: i18n.translate('xpack.osquery.liveQueryActions.error.notFoundParameters', {
defaultMessage:
"This query hasn't been called due to parameter used and its value not found in the alert.",
}),
}
: {}),
};
}
return { query };
};

View file

@ -11,6 +11,7 @@ import type {
CoreStart,
Plugin,
Logger,
Ecs,
} from '@kbn/core/server';
import { SavedObjectsClient } from '@kbn/core/server';
import type { DataRequestHandlerContext } from '@kbn/data-plugin/server';
@ -92,8 +93,8 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
this.telemetryEventsSender.setup(this.telemetryReceiver, plugins.taskManager, core.analytics);
return {
osqueryCreateAction: (params: CreateLiveQueryRequestBodySchema) =>
createActionHandler(osqueryContext, params),
osqueryCreateAction: (params: CreateLiveQueryRequestBodySchema, ecsData?: Ecs) =>
createActionHandler(osqueryContext, params, { ecsData }),
};
}

View file

@ -86,8 +86,7 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp
const { response: osqueryAction } = await createActionHandler(
osqueryContext,
request.body,
soClient,
{ currentUser }
{ soClient, metadata: { currentUser } }
);
return response.ok({

View file

@ -11,6 +11,8 @@ import type {
PluginSetup as DataPluginSetup,
PluginStart as DataPluginStart,
} from '@kbn/data-plugin/server';
import type { Ecs } from '@kbn/ecs';
import type { FleetStartContract } from '@kbn/fleet-plugin/server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { PluginSetupContract } from '@kbn/features-plugin/server';
@ -24,7 +26,7 @@ import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/
import type { CreateLiveQueryRequestBodySchema } from '../common/schemas/routes/live_query';
export interface OsqueryPluginSetup {
osqueryCreateAction: (payload: CreateLiveQueryRequestBodySchema) => void;
osqueryCreateAction: (payload: CreateLiveQueryRequestBodySchema, ecsData?: Ecs) => void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface

View file

@ -65,5 +65,6 @@
"@kbn/securitysolution-es-utils",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/std",
"@kbn/ecs",
]
}

View file

@ -9,7 +9,6 @@ import * as t from 'io-ts';
import { ecsMapping, arrayQueries } from '@kbn/osquery-io-ts-types';
export const OsqueryParams = t.type({
id: t.string,
query: t.union([t.string, t.undefined]),
ecs_mapping: t.union([ecsMapping, t.undefined]),
queries: t.union([arrayQueries, t.undefined]),
@ -18,7 +17,6 @@ export const OsqueryParams = t.type({
});
export const OsqueryParamsCamelCase = t.type({
id: t.string,
query: t.union([t.string, t.undefined]),
ecsMapping: t.union([ecsMapping, t.undefined]),
queries: t.union([arrayQueries, t.undefined]),

View file

@ -26,7 +26,7 @@ import { mockAlertDetailsData } from './__mocks__';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineTabs } from '../../../../common/types/timeline';
import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
jest.mock('../../../timelines/components/timeline/body/renderers', () => {
@ -43,6 +43,7 @@ jest.mock('../../../timelines/components/timeline/body/renderers', () => {
jest.mock('../../lib/kibana');
const originalKibanaLib = jest.requireActual('../../lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object
// The returned permissions object will indicate that the user does not have permissions by default
@ -202,6 +203,30 @@ describe('EventDetails', () => {
});
it('render osquery tab', async () => {
const {
services: { osquery },
} = useKibanaMock();
if (osquery) {
jest.spyOn(osquery, 'fetchAllLiveQueries').mockReturnValue({
data: {
// @ts-expect-error - we don't need all the response details to test the functionality
data: {
items: [
{
_id: 'testId',
_index: 'testIndex',
fields: {
action_id: ['testActionId'],
'queries.action_id': ['testQueryActionId'],
'queries.query': ['select * from users'],
'@timestamp': ['2022-09-08T18:16:30.256Z'],
},
},
],
},
},
});
}
const newProps = {
...defaultProps,
rawEventData: {

View file

@ -5,24 +5,18 @@
* 2.0.
*/
import {
EuiCode,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiNotificationBadge,
} from '@elastic/eui';
import { EuiCode, EuiEmptyPrompt, EuiNotificationBadge, EuiSpacer } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Ecs } from '../../../../common/ecs';
import { PERMISSION_DENIED } from '../../../detection_engine/rule_response_actions/osquery/translations';
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
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 { EventsViewType } from './event_details';
import * as i18n from './translations';
import type { RESPONSE_ACTION_TYPES } from '../../../../common/detection_engine/rule_response_actions/schemas/response_actions';
const TabContentWrapper = styled.div`
height: 100%;
@ -30,7 +24,7 @@ const TabContentWrapper = styled.div`
`;
type RuleParameters = Array<{
response_actions: Array<{
action_type_id: string;
action_type_id: RESPONSE_ACTION_TYPES.OSQUERY;
params: Record<string, unknown>;
}>;
}>;
@ -98,45 +92,41 @@ export const useOsqueryTab = ({
return;
}
const { OsqueryResults } = osquery;
const expandedEventFieldsObject = expandDottedObject(
rawEventData.fields
) as ExpandedEventFieldsObject;
const parameters = expandedEventFieldsObject.kibana?.alert?.rule?.parameters;
const responseActions = parameters?.[0].response_actions;
const responseActions =
expandedEventFieldsObject?.kibana?.alert?.rule?.parameters?.[0].response_actions;
const osqueryActionsLength = responseActions?.filter(
(action: { action_type_id: string }) => action.action_type_id === RESPONSE_ACTION_TYPES.OSQUERY
)?.length;
if (!osqueryActionsLength) {
if (!responseActions?.length) {
return;
}
const ruleName = expandedEventFieldsObject.kibana?.alert?.rule?.name;
const agentIds = expandedEventFieldsObject.agent?.id;
const { OsqueryResults, fetchAllLiveQueries } = osquery;
const alertId = rawEventData._id;
const { data: actionsData } = fetchAllLiveQueries({
filterQuery: { term: { alert_ids: alertId } },
activePage: 0,
limit: 100,
sortField: '@timestamp',
alertId,
});
const actionItems = actionsData?.data.items || [];
const ruleName = expandedEventFieldsObject.kibana?.alert?.rule?.name;
const agentIds = expandedEventFieldsObject.agent?.id;
return {
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>
name: i18n.OSQUERY_VIEW,
append: (
<EuiNotificationBadge data-test-subj="osquery-actions-notification">
{actionItems.length}
</EuiNotificationBadge>
),
content: (
<>
@ -144,12 +134,15 @@ export const useOsqueryTab = ({
{!application?.capabilities?.osquery?.read ? (
emptyPrompt
) : (
<OsqueryResults
agentIds={agentIds}
ruleName={ruleName}
alertId={alertId}
ecsData={ecsData}
/>
<>
<OsqueryResults
agentIds={agentIds}
ruleName={ruleName}
actionItems={actionItems}
ecsData={ecsData}
/>
<EuiSpacer size="s" />
</>
)}
</TabContentWrapper>
</>

View file

@ -5,291 +5,8 @@
* 2.0.
*/
import { pickBy, isEmpty } from 'lodash';
import type { Plugin } from 'unified';
import React, { useContext, useMemo, useState, useCallback } from 'react';
import type { RemarkTokenizer } from '@elastic/eui';
import {
EuiSpacer,
EuiCodeBlock,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiModalFooter,
EuiButton,
EuiButtonEmpty,
} from '@elastic/eui';
import { useForm, FormProvider } from 'react-hook-form';
import styled from 'styled-components';
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '../../../../lib/kibana';
import { LabelField } from './label_field';
import OsqueryLogo from './osquery_icon/osquery.svg';
import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout';
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
import { OsqueryNotAvailablePrompt } from './not_available_prompt';
import { plugin } from './plugin';
import { OsqueryParser } from './parser';
import { OsqueryRenderer } from './renderer';
const StyledEuiButton = styled(EuiButton)`
> span > img {
margin-block-end: 0;
}
`;
const OsqueryEditorComponent = ({
node,
onSave,
onCancel,
}: EuiMarkdownEditorUiPluginEditorProps<{
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
};
}>) => {
const isEditMode = node != null;
const {
osquery,
application: {
capabilities: { osquery: osqueryPermissions },
},
} = useKibana().services;
const formMethods = useForm<{
label: string;
query: string;
ecs_mapping: Record<string, unknown>;
}>({
defaultValues: {
label: node?.configuration?.label,
query: node?.configuration?.query,
ecs_mapping: node?.configuration?.ecs_mapping,
},
});
const onSubmit = useCallback(
(data) => {
onSave(
`!{osquery${JSON.stringify(
pickBy(
{
query: data.query,
label: data.label,
ecs_mapping: data.ecs_mapping,
},
(value) => !isEmpty(value)
)
)}}`,
{
block: true,
}
);
},
[onSave]
);
const noOsqueryPermissions = useMemo(
() =>
(!osqueryPermissions.runSavedQueries || !osqueryPermissions.readSavedQueries) &&
!osqueryPermissions.writeLiveQueries,
[
osqueryPermissions.readSavedQueries,
osqueryPermissions.runSavedQueries,
osqueryPermissions.writeLiveQueries,
]
);
const OsqueryActionForm = useMemo(() => {
if (osquery?.LiveQueryField) {
const { LiveQueryField } = osquery;
return (
<FormProvider {...formMethods}>
<LabelField />
<EuiSpacer size="m" />
<LiveQueryField formMethods={formMethods} />
</FormProvider>
);
}
return null;
}, [formMethods, osquery]);
if (noOsqueryPermissions) {
return <OsqueryNotAvailablePrompt />;
}
return (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalTitle"
defaultMessage="Edit query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalTitle"
defaultMessage="Add query"
/>
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<>{OsqueryActionForm}</>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate('xpack.securitySolution.markdown.osquery.modalCancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton onClick={formMethods.handleSubmit(onSubmit)} fill>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel"
defaultMessage="Add query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel"
defaultMessage="Save changes"
/>
)}
</EuiButton>
</EuiModalFooter>
</>
);
};
const OsqueryEditor = React.memo(OsqueryEditorComponent);
export const plugin = {
name: 'osquery',
button: {
label: 'Osquery',
iconType: 'logoOsquery',
},
helpText: (
<div>
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
{'!{osquery{options}}'}
</EuiCodeBlock>
<EuiSpacer size="s" />
</div>
),
editor: OsqueryEditor,
};
export const parser: Plugin = function () {
const Parser = this.Parser;
const tokenizers = Parser.prototype.blockTokenizers;
const methods = Parser.prototype.blockMethods;
const tokenizeOsquery: RemarkTokenizer = function (eat, value, silent) {
if (value.startsWith('!{osquery') === false) return false;
const nextChar = value[9];
if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a osquery
if (silent) {
return true;
}
// is there a configuration?
const hasConfiguration = nextChar === '{';
let match = '!{osquery';
let configuration = {};
if (hasConfiguration) {
let configurationString = '';
let openObjects = 0;
for (let i = 9; i < value.length; i++) {
const char = value[i];
if (char === '{') {
openObjects++;
configurationString += char;
} else if (char === '}') {
openObjects--;
if (openObjects === -1) {
break;
}
configurationString += char;
} else {
configurationString += char;
}
}
match += configurationString;
try {
configuration = JSON.parse(configurationString);
} catch (e) {
const now = eat.now();
this.file.fail(`Unable to parse osquery JSON configuration: ${e}`, {
line: now.line,
column: now.column + 9,
});
}
}
match += '}';
return eat(match)({
type: 'osquery',
configuration,
});
};
tokenizers.osquery = tokenizeOsquery;
methods.splice(methods.indexOf('text'), 0, 'osquery');
};
// receives the configuration from the parser and renders
const RunOsqueryButtonRenderer = ({
configuration,
}: {
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
test: [];
};
}) => {
const [showFlyout, setShowFlyout] = useState(false);
const { agentId, alertId } = useContext(BasicAlertDataContext);
const handleOpen = useCallback(() => setShowFlyout(true), [setShowFlyout]);
const handleClose = useCallback(() => setShowFlyout(false), [setShowFlyout]);
return (
<>
<StyledEuiButton iconType={OsqueryLogo} onClick={handleOpen}>
{configuration.label ??
i18n.translate('xpack.securitySolution.markdown.osquery.runOsqueryButtonLabel', {
defaultMessage: 'Run Osquery',
})}
</StyledEuiButton>
{showFlyout && (
<OsqueryFlyout
defaultValues={{
...(alertId ? { alertIds: [alertId] } : {}),
query: configuration.query,
ecs_mapping: configuration.ecs_mapping,
queryField: false,
}}
agentId={agentId}
onClose={handleClose}
/>
)}
</>
);
};
export { RunOsqueryButtonRenderer as renderer };
export { plugin, OsqueryParser as parser, OsqueryRenderer as renderer };

View file

@ -0,0 +1,76 @@
/*
* 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 { Plugin } from 'unified';
import type { RemarkTokenizer } from '@elastic/eui';
export const OsqueryParser: Plugin = function () {
const Parser = this.Parser;
const tokenizers = Parser.prototype.blockTokenizers;
const methods = Parser.prototype.blockMethods;
const tokenizeOsquery: RemarkTokenizer = function (eat, value, silent) {
if (value.startsWith('!{osquery') === false) return false;
const nextChar = value[9];
if (nextChar !== '{' && nextChar !== '}') return false; // this isn't actually a osquery
if (silent) {
return true;
}
// is there a configuration?
const hasConfiguration = nextChar === '{';
let match = '!{osquery';
let configuration = {};
if (hasConfiguration) {
let configurationString = '';
let openObjects = 0;
for (let i = 9; i < value.length; i++) {
const char = value[i];
if (char === '{') {
openObjects++;
configurationString += char;
} else if (char === '}') {
openObjects--;
if (openObjects === -1) {
break;
}
configurationString += char;
} else {
configurationString += char;
}
}
match += configurationString;
try {
configuration = JSON.parse(configurationString);
} catch (e) {
const now = eat.now();
this.file.fail(`Unable to parse osquery JSON configuration: ${e}`, {
line: now.line,
column: now.column + 9,
});
}
}
match += '}';
return eat(match)({
type: 'osquery',
configuration,
});
};
tokenizers.osquery = tokenizeOsquery;
methods.splice(methods.indexOf('text'), 0, 'osquery');
};

View file

@ -0,0 +1,172 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiCodeBlock,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
} from '@elastic/eui';
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
import { FormProvider, useForm } from 'react-hook-form';
import React, { useCallback, useMemo } from 'react';
import { isEmpty, pickBy } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { LabelField } from './label_field';
import { OsqueryNotAvailablePrompt } from './not_available_prompt';
import { useKibana } from '../../../../lib/kibana';
const OsqueryEditorComponent = ({
node,
onSave,
onCancel,
}: EuiMarkdownEditorUiPluginEditorProps<{
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
};
}>) => {
const isEditMode = node != null;
const {
osquery,
application: {
capabilities: { osquery: osqueryPermissions },
},
} = useKibana().services;
const formMethods = useForm<{
label: string;
query: string;
ecs_mapping: Record<string, unknown>;
}>({
defaultValues: {
label: node?.configuration?.label,
query: node?.configuration?.query,
ecs_mapping: node?.configuration?.ecs_mapping,
},
});
const onSubmit = useCallback(
(data) => {
onSave(
`!{osquery${JSON.stringify(
pickBy(
{
query: data.query,
label: data.label,
ecs_mapping: data.ecs_mapping,
},
(value) => !isEmpty(value)
)
)}}`,
{
block: true,
}
);
},
[onSave]
);
const noOsqueryPermissions = useMemo(
() =>
(!osqueryPermissions.runSavedQueries || !osqueryPermissions.readSavedQueries) &&
!osqueryPermissions.writeLiveQueries,
[
osqueryPermissions.readSavedQueries,
osqueryPermissions.runSavedQueries,
osqueryPermissions.writeLiveQueries,
]
);
const OsqueryActionForm = useMemo(() => {
if (osquery?.LiveQueryField) {
const { LiveQueryField } = osquery;
return (
<FormProvider {...formMethods}>
<LabelField />
<EuiSpacer size="m" />
<LiveQueryField formMethods={formMethods} />
</FormProvider>
);
}
return null;
}, [formMethods, osquery]);
if (noOsqueryPermissions) {
return <OsqueryNotAvailablePrompt />;
}
return (
<>
<EuiModalHeader>
<EuiModalHeaderTitle>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalTitle"
defaultMessage="Edit query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalTitle"
defaultMessage="Add query"
/>
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<>{OsqueryActionForm}</>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
{i18n.translate('xpack.securitySolution.markdown.osquery.modalCancelButtonLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton onClick={formMethods.handleSubmit(onSubmit)} fill>
{isEditMode ? (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel"
defaultMessage="Add query"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel"
defaultMessage="Save changes"
/>
)}
</EuiButton>
</EuiModalFooter>
</>
);
};
const OsqueryEditor = React.memo(OsqueryEditorComponent);
export const plugin = {
name: 'osquery',
button: {
label: 'Osquery',
iconType: 'logoOsquery',
},
helpText: (
<div>
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
{'!{osquery{options}}'}
</EuiCodeBlock>
<EuiSpacer size="s" />
</div>
),
editor: OsqueryEditor,
};

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.
*/
// receives the configuration from the parser and renders
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { reduce } from 'lodash';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { EuiButton } from '@elastic/eui';
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
import { expandDottedObject } from '../../../../../../common/utils/expand_dotted';
import type { Ecs } from '../../../../../../common/ecs';
import OsqueryLogo from './osquery_icon/osquery.svg';
import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout';
const StyledEuiButton = styled(EuiButton)`
> span > img {
margin-block-end: 0;
}
`;
export const OsqueryRenderer = ({
configuration,
}: {
configuration: {
label?: string;
query: string;
ecs_mapping: { [key: string]: {} };
test: [];
};
}) => {
const [showFlyout, setShowFlyout] = useState(false);
const { agentId, alertId, data } = useContext(BasicAlertDataContext);
const handleOpen = useCallback(() => setShowFlyout(true), [setShowFlyout]);
const handleClose = useCallback(() => setShowFlyout(false), [setShowFlyout]);
const ecsData = useMemo(() => {
const fieldsMap: Record<string, string> = reduce(
data,
(acc, eventDetailItem) => ({
...acc,
[eventDetailItem.field]: eventDetailItem?.values?.[0],
}),
{}
);
return expandDottedObject(fieldsMap) as Ecs;
}, [data]);
return (
<>
<StyledEuiButton iconType={OsqueryLogo} onClick={handleOpen}>
{configuration.label ??
i18n.translate('xpack.securitySolution.markdown.osquery.runOsqueryButtonLabel', {
defaultMessage: 'Run Osquery',
})}
</StyledEuiButton>
{showFlyout && (
<OsqueryFlyout
defaultValues={{
...(alertId ? { alertIds: [alertId] } : {}),
query: configuration.query,
ecs_mapping: configuration.ecs_mapping,
queryField: false,
}}
agentId={agentId}
onClose={handleClose}
ecsData={ecsData}
/>
)}
</>
);
};

View file

@ -70,6 +70,7 @@ export const useKibana = jest.fn().mockReturnValue({
},
osquery: {
OsqueryResults: jest.fn().mockReturnValue(null),
fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }),
},
timelines: createTGridMocks(),
savedObjectsTagging: {

View file

@ -0,0 +1,65 @@
/*
* 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useState } from 'react';
interface OsqueryInvestigationGuidePanelProps {
onClick: () => void;
}
const panelCss = {
marginBottom: '16px',
};
const flexGroupCss = { padding: `0 24px` };
export const OsqueryInvestigationGuidePanel = React.memo<OsqueryInvestigationGuidePanelProps>(
({ onClick }) => {
const [hideInvestigationGuideSuggestion, setHideInvestigationGuideSuggestion] = useState(false);
const handleClick = useCallback(() => {
onClick();
setHideInvestigationGuideSuggestion(true);
}, [onClick]);
if (hideInvestigationGuideSuggestion) {
return null;
}
return (
<EuiPanel color={'primary'} paddingSize={'xs'} css={panelCss}>
<EuiFlexGroup direction={'row'} alignItems={'center'} css={flexGroupCss}>
<EuiFlexItem grow={true}>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.responseActionsList.investigationGuideSuggestion"
defaultMessage="It seems that you have suggested queries in investigation guide, would you like to add them as response actions?"
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size={'m'}
color={'primary'}
onClick={handleClick}
data-test-subj={'osqueryAddInvestigationGuideQueries'}
>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.responseActionsList.addButton"
defaultMessage="Add"
/>
</EuiText>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
);
OsqueryInvestigationGuidePanel.displayName = 'OsqueryInvestigationGuidePanel';

View file

@ -15,6 +15,29 @@ import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_
import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useParams: () => ({
detailName: 'testId',
}),
}));
jest.mock('../../common/lib/kibana', () => {
const original = jest.requireActual('../../common/lib/kibana');
return {
...original,
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
remove: jest.fn(),
}),
};
});
import * as rules from '../rule_management/logic/use_rule';
// @ts-expect-error we don't really care about thr useRule return value
jest.spyOn(rules, 'useRule').mockReturnValue({});
const renderWithContext = (Element: React.ReactElement) => {
const mockTheme = getMockTheme({ eui: { euiColorLightestShade: '#F5F7FA' } });
@ -38,7 +61,8 @@ describe('ResponseActionsForm', () => {
const { getByTestId, queryByTestId } = renderWithContext(<Component items={[]} />);
expect(getByTestId('response-actions-form'));
expect(getByTestId('response-actions-header'));
expect(getByTestId('response-actions-list'));
expect(getByTestId('response-actions-wrapper'));
expect(queryByTestId('response-actions-list'));
expect(queryByTestId('response-actions-list-item-0')).toEqual(null);
});
it('renders list of elements', async () => {

View file

@ -10,9 +10,9 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { map, reduce, upperFirst } from 'lodash';
import ReactMarkdown from 'react-markdown';
import { css } from '@emotion/react';
import { ResponseActionsWrapper } from './response_actions_wrapper';
import { FORM_ERRORS_TITLE } from '../../detections/components/rules/rule_actions_field/translations';
import { ResponseActionsHeader } from './response_actions_header';
import { ResponseActionsList } from './response_actions_list';
import type { ArrayItem, FormHook } from '../../shared_imports';
import { useSupportedResponseActionTypes } from './use_supported_response_action_types';
@ -44,7 +44,7 @@ export const ResponseActionsForm = ({
}
return (
<ResponseActionsList
<ResponseActionsWrapper
items={items}
removeItem={removeItem}
supportedResponseActionTypes={supportedResponseActionTypes}

View file

@ -5,67 +5,59 @@
* 2.0.
*/
import React, { useCallback, useEffect, useRef, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import type { ResponseActionType } from './get_supported_response_actions';
import { ResponseActionAddButton } from './response_action_add_button';
import { useParams } from 'react-router-dom';
import { OsqueryInvestigationGuidePanel } from './osquery/osquery_investigation_guide_panel';
import { useRule } from '../rule_management/logic';
import { ResponseActionTypeForm } from './response_action_type_form';
import type { ArrayItem } from '../../shared_imports';
import { UseField, useFormContext } from '../../shared_imports';
import { UseField, useFormContext, useFormData } from '../../shared_imports';
import { getResponseActionsFromNote, getOsqueryQueriesFromNote } from './utils';
interface ResponseActionsListProps {
items: ArrayItem[];
removeItem: (id: number) => void;
addItem: () => void;
supportedResponseActionTypes: ResponseActionType[];
}
const GhostFormField = () => <></>;
export const ResponseActionsList = React.memo(
({ items, removeItem, supportedResponseActionTypes, addItem }: ResponseActionsListProps) => {
const actionTypeIdRef = useRef<string | null>(null);
const updateActionTypeId = useCallback((id) => {
actionTypeIdRef.current = id;
}, []);
export const ResponseActionsList = React.memo<ResponseActionsListProps>(({ items, removeItem }) => {
const { detailName: ruleId } = useParams<{ detailName: string }>();
const { data: rule } = useRule(ruleId);
const context = useFormContext();
const renderButton = useMemo(() => {
return (
<ResponseActionAddButton
supportedResponseActionTypes={supportedResponseActionTypes}
addActionType={addItem}
updateActionTypeId={updateActionTypeId}
/>
);
}, [addItem, updateActionTypeId, supportedResponseActionTypes]);
const osqueryNoteQueries = useMemo(
() => (rule?.note ? getOsqueryQueriesFromNote(rule.note) : []),
[rule?.note]
);
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]);
const context = useFormContext();
const [formData] = useFormData();
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} />
const handleInvestigationGuideClick = useCallback(() => {
const values = getResponseActionsFromNote(osqueryNoteQueries, formData.responseActions);
context.updateFieldValues(values);
}, [context, formData?.responseActions, osqueryNoteQueries]);
<UseField path={`${actionItem.path}.actionTypeId`} component={GhostFormField} />
</div>
);
})}
<EuiSpacer size="m" />
{renderButton}
</div>
);
}
);
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} />
<UseField path={`${actionItem.path}.actionTypeId`} component={GhostFormField} />
</div>
);
})}
<EuiSpacer size="m" />
{osqueryNoteQueries.length ? (
<OsqueryInvestigationGuidePanel onClick={handleInvestigationGuideClick} />
) : null}
</div>
);
});
ResponseActionsList.displayName = 'ResponseActionsList';

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, { useCallback, useEffect, useRef, useMemo } from 'react';
import { ResponseActionsList } from './response_actions_list';
import type { ResponseActionType } from './get_supported_response_actions';
import { ResponseActionAddButton } from './response_action_add_button';
import type { ArrayItem } from '../../shared_imports';
import { useFormContext } from '../../shared_imports';
interface ResponseActionsWrapperProps {
items: ArrayItem[];
removeItem: (id: number) => void;
addItem: () => void;
supportedResponseActionTypes: ResponseActionType[];
}
export const ResponseActionsWrapper = React.memo<ResponseActionsWrapperProps>(
({ items, removeItem, supportedResponseActionTypes, addItem }) => {
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-wrapper">
<ResponseActionsList items={items} removeItem={removeItem} />
{renderButton}
</div>
);
}
);
ResponseActionsWrapper.displayName = 'ResponseActionsWrapper';

View file

@ -0,0 +1,89 @@
/*
* 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 { getOsqueryQueriesFromNote } from './utils';
describe('getOsqueryQueriesFromNote', () => {
it('should transform investigation guide note into osquery queries', () => {
const note =
'!{osquery{"query":"SELECT * FROM processes where pid = {{ process.pid }};","label":"Get processes","ecs_mapping":{"process.pid":{"field":"pid"},"process.name":{"field":"name"},"process.executable":{"field":"path"},"process.args":{"field":"cmdline"},"process.working_directory":{"field":"cwd"},"user.id":{"field":"uid"},"group.id":{"field":"gid"},"process.parent.pid":{"field":"parent"},"process.pgid":{"field":"pgroup"}}}}\n\n!{osquery{"query":"select * from users;","label":"Get users"}}';
const queries = getOsqueryQueriesFromNote(note);
const expectedQueries = [
{
type: 'osquery',
configuration: {
query: 'SELECT * FROM processes where pid = {{ process.pid }};',
label: 'Get processes',
ecs_mapping: {
'process.pid': {
field: 'pid',
},
'process.name': {
field: 'name',
},
'process.executable': {
field: 'path',
},
'process.args': {
field: 'cmdline',
},
'process.working_directory': {
field: 'cwd',
},
'user.id': {
field: 'uid',
},
'group.id': {
field: 'gid',
},
'process.parent.pid': {
field: 'parent',
},
'process.pgid': {
field: 'pgroup',
},
},
},
position: {
start: {
line: 1,
column: 1,
offset: 0,
},
end: {
line: 1,
column: 423,
offset: 422,
},
indent: [],
},
},
{
type: 'osquery',
configuration: {
query: 'select * from users;',
label: 'Get users',
},
position: {
start: {
line: 3,
column: 1,
offset: 424,
},
end: {
line: 3,
column: 63,
offset: 486,
},
indent: [],
},
},
];
expect(queries).toEqual(expectedQueries);
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 unified from 'unified';
import markdown from 'remark-parse';
import { filter, reduce } from 'lodash';
import type { ECSMapping } from '@kbn/osquery-io-ts-types';
import type { RuleResponseAction } from '../../../common/detection_engine/rule_response_actions/schemas';
import { RESPONSE_ACTION_TYPES } from '../../../common/detection_engine/rule_response_actions/schemas';
import { OsqueryParser } from '../../common/components/markdown_editor/plugins/osquery/parser';
interface OsqueryNoteQuery {
configuration: {
label: string;
query: string;
ecs_mapping: ECSMapping;
};
}
export const getOsqueryQueriesFromNote = (note: string): OsqueryNoteQuery[] => {
const parsedAlertInvestigationGuide = unified()
.use([[markdown, {}], OsqueryParser])
.parse(note);
return filter(parsedAlertInvestigationGuide?.children as object, ['type', 'osquery']);
};
export const getResponseActionsFromNote = (
osqueryQueries: OsqueryNoteQuery[],
defaultResponseActions: RuleResponseAction[] = []
) => {
return reduce(
osqueryQueries,
(acc: { responseActions: RuleResponseAction[] }, { configuration }: OsqueryNoteQuery) => {
const responseActionPath = 'responseActions';
acc[responseActionPath].push({
actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY,
params: {
savedQueryId: undefined,
packId: undefined,
queries: undefined,
query: configuration.query,
ecsMapping: configuration.ecs_mapping,
},
});
return acc;
},
{ responseActions: defaultResponseActions }
);
};

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { EuiFlyout, EuiFlyoutFooter, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { useQueryClient } from '@tanstack/react-query';
import type { Ecs } from '../../../../common/ecs';
import { useKibana } from '../../../common/lib/kibana';
import { OsqueryEventDetailsFooter } from './osquery_flyout_footer';
@ -19,11 +20,19 @@ const OsqueryActionWrapper = styled.div`
export interface OsqueryFlyoutProps {
agentId?: string;
defaultValues?: {};
defaultValues?: {
alertIds?: string[];
query?: string;
ecs_mapping?: { [key: string]: {} };
queryField?: boolean;
};
onClose: () => void;
ecsData?: Ecs;
}
// Make sure we keep this and ACTIONS_QUERY_KEY in use_all_live_queries.ts in sync.
const ACTIONS_QUERY_KEY = 'actions';
const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
agentId,
defaultValues,
@ -33,6 +42,13 @@ const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
const {
services: { osquery },
} = useKibana();
const queryClient = useQueryClient();
const invalidateQueries = useCallback(() => {
queryClient.invalidateQueries({
queryKey: [ACTIONS_QUERY_KEY, { alertId: defaultValues?.alertIds?.[0] }],
});
}, [defaultValues?.alertIds, queryClient]);
if (osquery?.OsqueryAction) {
return (
@ -54,6 +70,7 @@ const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
formType="steps"
defaultValues={defaultValues}
ecsData={ecsData}
onSuccess={invalidateQueries}
/>
</OsqueryActionWrapper>
</EuiFlyoutBody>

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 { scheduleNotificationResponseActions } from './schedule_notification_response_actions';
import { RESPONSE_ACTION_TYPES } from '../../../../common/detection_engine/rule_response_actions/schemas';
describe('ScheduleNotificationResponseActions', () => {
const signalOne = { agent: { id: 'agent-id-1' }, _id: 'alert-id-1', user: { id: 'S-1-5-20' } };
const signalTwo = { agent: { id: 'agent-id-2' }, _id: 'alert-id-2' };
const signals = [signalOne, signalTwo];
const defaultQueryParams = {
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: 'testSavedQueryId',
query: undefined,
queries: [],
packId: undefined,
};
const defaultPackParams = {
packId: 'testPackId',
queries: [],
query: undefined,
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: undefined,
};
const defaultQueries = {
ecs_mapping: undefined,
platform: 'windows',
version: '1.0.0',
snapshot: true,
removed: false,
};
const defaultResultParams = {
agent_ids: ['agent-id-1', 'agent-id-2'],
alert_ids: ['alert-id-1', 'alert-id-2'],
};
const defaultQueryResultParams = {
...defaultResultParams,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
ecsMapping: undefined,
saved_query_id: 'testSavedQueryId',
savedQueryId: undefined,
queries: [],
};
const defaultPackResultParams = {
...defaultResultParams,
query: undefined,
saved_query_id: undefined,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
};
const simpleQuery = 'select * from uptime';
it('should handle osquery response actions with query', async () => {
const osqueryActionMock = jest.fn();
const responseActions = [
{
actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY,
params: {
...defaultQueryParams,
query: simpleQuery,
},
},
];
scheduleNotificationResponseActions({ signals, responseActions }, osqueryActionMock);
expect(osqueryActionMock).toHaveBeenCalledWith({
...defaultQueryResultParams,
query: simpleQuery,
});
//
});
it('should handle osquery response actions with packs', async () => {
const osqueryActionMock = jest.fn();
const responseActions = [
{
actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY,
params: {
...defaultPackParams,
queries: [
{
...defaultQueries,
id: 'query-1',
query: simpleQuery,
},
],
packId: 'testPackId',
},
},
];
scheduleNotificationResponseActions({ signals, responseActions }, osqueryActionMock);
expect(osqueryActionMock).toHaveBeenCalledWith({
...defaultPackResultParams,
queries: [{ ...defaultQueries, id: 'query-1', query: simpleQuery }],
});
});
});

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { map, uniq } from 'lodash';
import type { Ecs } from '@kbn/ecs';
import { uniq, reduce, some, each } 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';
@ -15,31 +16,68 @@ interface ScheduleNotificationActions {
responseActions: RuleResponseAction[];
}
interface IAlert {
agent: {
id: string;
};
interface AlertsWithAgentType {
alerts: Ecs[];
agents: string[];
alertIds: string[];
}
const CONTAINS_DYNAMIC_PARAMETER_REGEX = /\{{([^}]+)\}}/g; // when there are 2 opening and 2 closing curly brackets (including brackets)
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');
const filteredAlerts = (signals as Ecs[]).filter((alert) => alert.agent?.id);
responseActions.forEach((responseAction) => {
const { alerts, agents, alertIds }: AlertsWithAgentType = reduce(
filteredAlerts,
(acc, alert) => {
const agentId = alert.agent?.id;
if (agentId !== undefined) {
return {
alerts: [...acc.alerts, alert],
agents: [...acc.agents, agentId],
alertIds: [...acc.alertIds, (alert as unknown as { _id: string })._id],
};
}
return acc;
},
{ alerts: [], agents: [], alertIds: [] } as AlertsWithAgentType
);
const agentIds = uniq(agents);
each(responseActions, (responseAction) => {
if (responseAction.actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY && osqueryCreateAction) {
const temporaryQueries = responseAction.params.queries?.length
? responseAction.params.queries
: [{ query: responseAction.params.query }];
const containsDynamicQueries = some(temporaryQueries, (query) => {
return query.query ? CONTAINS_DYNAMIC_PARAMETER_REGEX.test(query.query) : false;
});
const { savedQueryId, packId, queries, ecsMapping, ...rest } = responseAction.params;
return osqueryCreateAction({
...rest,
queries,
ecs_mapping: ecsMapping,
saved_query_id: savedQueryId,
agent_ids: agentIds,
alert_ids: alertIds,
if (!containsDynamicQueries) {
return osqueryCreateAction({
...rest,
queries,
ecs_mapping: ecsMapping,
saved_query_id: savedQueryId,
agent_ids: agentIds,
alert_ids: alertIds,
});
}
each(alerts, (alert) => {
return osqueryCreateAction(
{
...rest,
queries,
ecs_mapping: ecsMapping,
saved_query_id: savedQueryId,
agent_ids: alert.agent?.id ? [alert.agent.id] : [],
alert_ids: [(alert as unknown as { _id: string })._id],
},
alert
);
});
}
});

View file

@ -125,6 +125,7 @@
"@kbn/core-status-common-internal",
"@kbn/repo-info",
"@kbn/storybook",
"@kbn/ecs",
"@kbn/cypress-config",
"@kbn/controls-plugin",
"@kbn/shared-ux-utility",