[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, ecs_mapping: ecsMappingOrUndefined,
version: versionOrUndefined, version: versionOrUndefined,
platform: platformOrUndefined, platform: platformOrUndefined,
removed: removedOrUndefined,
snapshot: snapshotOrUndefined,
}) })
); );
export type ArrayQueries = t.TypeOf<typeof arrayQueries>; export type ArrayQueries = t.TypeOf<typeof arrayQueries>;
@ -111,10 +113,12 @@ export const objectQueries = t.record(
version: versionOrUndefined, version: versionOrUndefined,
platform: platformOrUndefined, platform: platformOrUndefined,
saved_query_id: savedQueryIdOrUndefined, saved_query_id: savedQueryIdOrUndefined,
removed: removedOrUndefined,
snapshot: snapshotOrUndefined,
}) })
); );
export type ObjectQueries = t.TypeOf<typeof objectQueries>; export type ObjectQueries = t.TypeOf<typeof objectQueries>;
export const queries = t.union([arrayQueries, objectQueries]); export const queries = t.union([arrayQueries, objectQueries]);
export type Queries = t.TypeOf<typeof queries>; 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>; export type QueriesOrUndefined = t.TypeOf<typeof queriesOrUndefined>;

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import {
savedQueryIdOrUndefined, savedQueryIdOrUndefined,
packIdOrUndefined, packIdOrUndefined,
queryOrUndefined, queryOrUndefined,
queriesOrUndefined, arrayQueries,
} from '@kbn/osquery-io-ts-types'; } from '@kbn/osquery-io-ts-types';
export const createLiveQueryRequestBodySchema = t.partial({ export const createLiveQueryRequestBodySchema = t.partial({
@ -21,7 +21,7 @@ export const createLiveQueryRequestBodySchema = t.partial({
agent_platforms: t.array(t.string), agent_platforms: t.array(t.string),
agent_policy_ids: t.array(t.string), agent_policy_ids: t.array(t.string),
query: queryOrUndefined, query: queryOrUndefined,
queries: queriesOrUndefined, queries: arrayQueries,
saved_query_id: savedQueryIdOrUndefined, saved_query_id: savedQueryIdOrUndefined,
ecs_mapping: ecsMappingOrUndefined, ecs_mapping: ecsMappingOrUndefined,
pack_id: packIdOrUndefined, 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_1,
RESPONSE_ACTIONS_ITEM_2, RESPONSE_ACTIONS_ITEM_2,
OSQUERY_RESPONSE_ACTION_ADD_BUTTON, OSQUERY_RESPONSE_ACTION_ADD_BUTTON,
RESPONSE_ACTIONS_ITEM_3,
} from '../../tasks/response_actions'; } from '../../tasks/response_actions';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { login } from '../../tasks/login'; 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)', () => { it('should be able to run live query and add to timeline (-depending on the previous test)', () => {
const TIMELINE_NAME = 'Untitled timeline'; const TIMELINE_NAME = 'Untitled timeline';
cy.visit('/app/security/alerts'); 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_0 = 'response-actions-list-item-0';
export const RESPONSE_ACTIONS_ITEM_1 = 'response-actions-list-item-1'; 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_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'; export const OSQUERY_RESPONSE_ACTION_ADD_BUTTON = 'osquery-response-action-type-selection-option';

View file

@ -18,6 +18,7 @@ interface ActionResultsSummaryProps {
actionId: string; actionId: string;
expirationDate?: string; expirationDate?: string;
agentIds?: string[]; agentIds?: string[];
error?: string;
} }
const renderErrorMessage = (error: string) => ( const renderErrorMessage = (error: string) => (
@ -30,6 +31,7 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
actionId, actionId,
expirationDate, expirationDate,
agentIds, agentIds,
error,
}) => { }) => {
const [pageIndex] = useState(0); const [pageIndex] = useState(0);
const [pageSize] = useState(50); const [pageSize] = useState(50);
@ -52,22 +54,42 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
isLive, isLive,
skip: !hasActionResultsPrivileges, skip: !hasActionResultsPrivileges,
}); });
if (expired) {
edges.forEach((edge) => { useEffect(() => {
if (!edge.fields?.completed_at && edge.fields) { if (error) {
edge.fields['error.keyword'] = edge.fields.error = [ edges.forEach((edge) => {
i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredErrorText', { if (edge.fields) {
defaultMessage: 'The action request timed out.', 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 renderAgentIdColumn = useCallback((agentId) => <AgentIdToName agentId={agentId} />, []);
const renderRowsColumn = useCallback((rowsCount) => rowsCount ?? '-', []); const renderRowsColumn = useCallback((rowsCount) => rowsCount ?? '-', []);
const renderStatusColumn = useCallback( const renderStatusColumn = useCallback(
(_, item) => { (_, item) => {
if (item.fields['error.skipped']) {
return i18n.translate('xpack.osquery.liveQueryActionResults.table.skippedStatusText', {
defaultMessage: 'skipped',
});
}
if (!item.fields.completed_at) { if (!item.fields.completed_at) {
return expired return expired
? i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredStatusText', { ? i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredStatusText', {
@ -139,11 +161,11 @@ const ActionResultsSummaryComponent: React.FC<ActionResultsSummaryProps> = ({
useEffect(() => { useEffect(() => {
setIsLive(() => { setIsLive(() => {
if (!agentIds?.length || expired) return false; if (!agentIds?.length || expired || error) return false;
return !!(aggregations.totalResponded !== agentIds?.length); return !!(aggregations.totalResponded !== agentIds?.length);
}); });
}, [agentIds?.length, aggregations.totalResponded, expired]); }, [agentIds?.length, aggregations.totalResponded, error, expired]);
return edges.length ? ( return edges.length ? (
<EuiInMemoryTable loading={isLive} items={edges} columns={columns} pagination={pagination} /> <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 { i18n } from '@kbn/i18n';
import { createFilter } from '../common/helpers'; import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana'; 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 type { ESTermQuery, ESExistsQuery } from '../../common/typed_json';
import { useErrorToast } from '../common/hooks/use_error_toast'; import { useErrorToast } from '../common/hooks/use_error_toast';
import { Direction } from '../../common/search_strategy';
interface UseAllLiveQueries { export interface UseAllLiveQueriesConfig {
activePage: number; activePage: number;
direction: Direction; direction?: Direction;
limit: number; limit: number;
sortField: string; sortField: string;
filterQuery?: ESTermQuery | ESExistsQuery | string; filterQuery?: ESTermQuery | ESExistsQuery | string;
skip?: boolean; 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 = ({ export const useAllLiveQueries = ({
activePage, activePage,
direction, direction = Direction.desc,
limit, limit,
sortField, sortField,
filterQuery, filterQuery,
skip = false, skip = false,
}: UseAllLiveQueries) => { alertId,
}: UseAllLiveQueriesConfig) => {
const { http } = useKibana().services; const { http } = useKibana().services;
const setErrorToast = useErrorToast(); const setErrorToast = useErrorToast();
return useQuery( return useQuery(
['actions', { activePage, direction, limit, sortField }], [
ACTIONS_QUERY_KEY,
{ activePage, direction, limit, sortField, ...(alertId ? { alertId } : {}) },
],
() => () =>
http.get<{ data: Omit<ActionsStrategyResponse, 'edges'> & { items: ActionEdges } }>( http.get<{ data: Omit<ActionsStrategyResponse, 'edges'> & { items: ActionEdges } }>(
'/api/osquery/live_queries', '/api/osquery/live_queries',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,9 +10,7 @@ import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import type { CoreStart } from '@kbn/core/public'; import type { CoreStart } from '@kbn/core/public';
import { useAllLiveQueries } from '../../actions/use_all_live_queries';
import { KibanaContextProvider } from '../../common/lib/kibana'; import { KibanaContextProvider } from '../../common/lib/kibana';
import { Direction } from '../../../common/search_strategy';
import { queryClient } from '../../query_client'; import { queryClient } from '../../query_client';
import { KibanaThemeProvider } from '../../shared_imports'; import { KibanaThemeProvider } from '../../shared_imports';
@ -23,41 +21,30 @@ import { OsqueryResult } from './osquery_result';
const OsqueryActionResultsComponent: React.FC<OsqueryActionResultsProps> = ({ const OsqueryActionResultsComponent: React.FC<OsqueryActionResultsProps> = ({
agentIds, agentIds,
ruleName, ruleName,
alertId, actionItems,
ecsData, ecsData,
}) => { }) => (
const { data: actionsData } = useAllLiveQueries({ <div data-test-subj={'osquery-results'}>
filterQuery: { term: { alert_ids: alertId } }, {actionItems?.map((item) => {
activePage: 0, const actionId = item.fields?.action_id?.[0];
limit: 100, const queryId = item.fields?.['queries.action_id']?.[0];
direction: Direction.desc, const startDate = item.fields?.['@timestamp'][0];
sortField: '@timestamp',
});
return ( return (
<div data-test-subj={'osquery-results'}> <OsqueryResult
{actionsData?.data.items.map((item, index) => { key={actionId}
const actionId = item.fields?.action_id?.[0]; actionId={actionId}
const queryId = item.fields?.['queries.action_id']?.[0]; queryId={queryId}
// const query = item.fields?.['queries.query']?.[0]; startDate={startDate}
const startDate = item.fields?.['@timestamp'][0]; ruleName={ruleName}
agentIds={agentIds}
return ( ecsData={ecsData}
<OsqueryResult />
key={actionId + index} );
actionId={actionId} })}
queryId={queryId} <EuiSpacer size="s" />
startDate={startDate} </div>
ruleName={ruleName} );
agentIds={agentIds}
ecsData={ecsData}
/>
);
})}
<EuiSpacer size="s" />
</div>
);
};
export const OsqueryActionResults = React.memo(OsqueryActionResultsComponent); 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 { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table';
import { AlertAttachmentContext } from '../../common/contexts'; import { AlertAttachmentContext } from '../../common/contexts';
interface OsqueryResultProps extends Omit<OsqueryActionResultsProps, 'alertId'> { interface OsqueryResultProps extends OsqueryActionResultsProps {
actionId: string; actionId: string;
queryId: string; queryId: string;
startDate: string; startDate: string;

View file

@ -13,11 +13,11 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { OsqueryActionResults } from '.'; import { OsqueryActionResults } from '.';
import { queryClient } from '../../query_client'; import { queryClient } from '../../query_client';
import { useKibana } from '../../common/lib/kibana'; 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 * as useLiveQueryDetails from '../../actions/use_live_query_details';
import { PERMISSION_DENIED } from '../osquery_action/translations'; import { PERMISSION_DENIED } from '../osquery_action/translations';
import * as privileges from '../../action_results/use_action_privileges'; import * as privileges from '../../action_results/use_action_privileges';
import { defaultLiveQueryDetails, DETAILS_QUERY, getMockedKibanaConfig } from './test_utils'; import { defaultLiveQueryDetails, DETAILS_QUERY, getMockedKibanaConfig } from './test_utils';
import type { OsqueryActionResultsProps } from './types';
jest.mock('../../common/lib/kibana'); jest.mock('../../common/lib/kibana');
@ -31,11 +31,21 @@ const enablePrivileges = () => {
})); }));
}; };
const defaultProps = { const defaultProps: OsqueryActionResultsProps = {
agentIds: ['agent1'], agentIds: ['agent1'],
ruleName: ['Test-rule'], ruleName: ['Test-rule'],
ruleActions: [{ action_type_id: 'action1' }, { action_type_id: 'action2' }], actionItems: [
alertId: 'test-alert-id', {
_id: 'test',
_index: 'test',
fields: {
action_id: ['testActionId'],
'queries.action_id': ['queriesActionId'],
'queries.query': [DETAILS_QUERY],
'@timestamp': ['2022-09-08T18:16:30.256Z'],
},
},
],
ecsData: { ecsData: {
_id: 'test', _id: 'test',
}, },
@ -66,23 +76,6 @@ const renderWithContext = (Element: React.ReactElement) =>
describe('Osquery Results', () => { describe('Osquery Results', () => {
beforeAll(() => { beforeAll(() => {
mockKibana(); 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 jest
.spyOn(useLiveQueryDetails, 'useLiveQueryDetails') .spyOn(useLiveQueryDetails, 'useLiveQueryDetails')

View file

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

View file

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

View file

@ -7,16 +7,16 @@
import uuid from 'uuid'; import uuid from 'uuid';
import moment from 'moment'; 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 { 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 { getInternalSavedObjectsClient } from '../../routes/utils';
import { parseAgentSelection } from '../../lib/parse_agent_groups'; import { parseAgentSelection } from '../../lib/parse_agent_groups';
import { packSavedObjectType } from '../../../common/types'; import { packSavedObjectType } from '../../../common/types';
import type { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import type { OsqueryAppContext } from '../../lib/osquery_app_context_services';
import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query'; import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query';
import { convertSOQueriesToPack } from '../../routes/pack/utils'; import { convertSOQueriesToPack } from '../../routes/pack/utils';
import { isSavedQueryPrebuilt } from '../../routes/saved_query/utils';
import { ACTIONS_INDEX } from '../../../common/constants'; import { ACTIONS_INDEX } from '../../../common/constants';
import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants'; import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants';
import type { PackSavedObjectAttributes } from '../../common/types'; import type { PackSavedObjectAttributes } from '../../common/types';
@ -25,17 +25,23 @@ interface Metadata {
currentUser: string | undefined; currentUser: string | undefined;
} }
interface CreateActionHandlerOptions {
soClient?: SavedObjectsClientContract;
metadata?: Metadata;
ecsData?: Ecs;
}
export const createActionHandler = async ( export const createActionHandler = async (
osqueryContext: OsqueryAppContext, osqueryContext: OsqueryAppContext,
params: CreateLiveQueryRequestBodySchema, params: CreateLiveQueryRequestBodySchema,
soClient?: SavedObjectsClientContract, options: CreateActionHandlerOptions
metadata?: Metadata
) => { ) => {
const [coreStartServices] = await osqueryContext.getStartServices(); const [coreStartServices] = await osqueryContext.getStartServices();
const esClientInternal = coreStartServices.elasticsearch.client.asInternalUser; const esClientInternal = coreStartServices.elasticsearch.client.asInternalUser;
const internalSavedObjectsClient = await getInternalSavedObjectsClient( const internalSavedObjectsClient = await getInternalSavedObjectsClient(
osqueryContext.getStartServices osqueryContext.getStartServices
); );
const { soClient, metadata, ecsData } = options;
const savedObjectsClient = soClient ?? coreStartServices.savedObjects.createInternalRepository(); const savedObjectsClient = soClient ?? coreStartServices.savedObjects.createInternalRepository();
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -46,6 +52,7 @@ export const createActionHandler = async (
platformsSelected: agent_platforms, platformsSelected: agent_platforms,
policiesSelected: agent_policy_ids, policiesSelected: agent_policy_ids,
}); });
if (!selectedAgents.length) { if (!selectedAgents.length) {
throw new Error('No agents found for selection'); throw new Error('No agents found for selection');
} }
@ -95,49 +102,24 @@ export const createActionHandler = async (
(value) => !isEmpty(value) (value) => !isEmpty(value)
) )
) )
: params.queries?.length : ecsData
? map(params.queries, (query) => ? await createDynamicQueries(params, ecsData, osqueryContext)
pickBy( : await createQueries(params, selectedAgents, osqueryContext),
{
// @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) => ({ const fleetActions = map(
action_id: query.action_id, filter(osqueryAction.queries, (query) => !query.error),
'@timestamp': moment().toISOString(), (query) => ({
expiration: moment().add(5, 'minutes').toISOString(), action_id: query.action_id,
type: 'INPUT_ACTION', '@timestamp': moment().toISOString(),
input_type: 'osquery', expiration: moment().add(5, 'minutes').toISOString(),
agents: query.agents, type: 'INPUT_ACTION',
user_id: metadata?.currentUser, input_type: 'osquery',
data: pick(query, ['id', 'query', 'ecs_mapping', 'version', 'platform']), agents: query.agents,
})); user_id: metadata?.currentUser,
data: pick(query, ['id', 'query', 'ecs_mapping', 'version', 'platform']),
})
);
await esClientInternal.bulk({ await esClientInternal.bulk({
refresh: 'wait_for', 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, CoreStart,
Plugin, Plugin,
Logger, Logger,
Ecs,
} from '@kbn/core/server'; } from '@kbn/core/server';
import { SavedObjectsClient } from '@kbn/core/server'; import { SavedObjectsClient } from '@kbn/core/server';
import type { DataRequestHandlerContext } from '@kbn/data-plugin/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); this.telemetryEventsSender.setup(this.telemetryReceiver, plugins.taskManager, core.analytics);
return { return {
osqueryCreateAction: (params: CreateLiveQueryRequestBodySchema) => osqueryCreateAction: (params: CreateLiveQueryRequestBodySchema, ecsData?: Ecs) =>
createActionHandler(osqueryContext, params), createActionHandler(osqueryContext, params, { ecsData }),
}; };
} }

View file

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

View file

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

View file

@ -65,5 +65,6 @@
"@kbn/securitysolution-es-utils", "@kbn/securitysolution-es-utils",
"@kbn/core-elasticsearch-client-server-mocks", "@kbn/core-elasticsearch-client-server-mocks",
"@kbn/std", "@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'; import { ecsMapping, arrayQueries } from '@kbn/osquery-io-ts-types';
export const OsqueryParams = t.type({ export const OsqueryParams = t.type({
id: t.string,
query: t.union([t.string, t.undefined]), query: t.union([t.string, t.undefined]),
ecs_mapping: t.union([ecsMapping, t.undefined]), ecs_mapping: t.union([ecsMapping, t.undefined]),
queries: t.union([arrayQueries, t.undefined]), queries: t.union([arrayQueries, t.undefined]),
@ -18,7 +17,6 @@ export const OsqueryParams = t.type({
}); });
export const OsqueryParamsCamelCase = t.type({ export const OsqueryParamsCamelCase = t.type({
id: t.string,
query: t.union([t.string, t.undefined]), query: t.union([t.string, t.undefined]),
ecsMapping: t.union([ecsMapping, t.undefined]), ecsMapping: t.union([ecsMapping, t.undefined]),
queries: t.union([arrayQueries, 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 type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineTabs } from '../../../../common/types/timeline'; import { TimelineTabs } from '../../../../common/types/timeline';
import { useInvestigationTimeEnrichment } from '../../containers/cti/event_enrichment'; 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'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
jest.mock('../../../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'); jest.mock('../../lib/kibana');
const originalKibanaLib = jest.requireActual('../../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 // 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 // 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 () => { 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 = { const newProps = {
...defaultProps, ...defaultProps,
rawEventData: { rawEventData: {

View file

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

View file

@ -5,291 +5,8 @@
* 2.0. * 2.0.
*/ */
import { pickBy, isEmpty } from 'lodash'; import { plugin } from './plugin';
import type { Plugin } from 'unified'; import { OsqueryParser } from './parser';
import React, { useContext, useMemo, useState, useCallback } from 'react'; import { OsqueryRenderer } from './renderer';
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';
const StyledEuiButton = styled(EuiButton)` export { plugin, OsqueryParser as parser, OsqueryRenderer as renderer };
> 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 };

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: { osquery: {
OsqueryResults: jest.fn().mockReturnValue(null), OsqueryResults: jest.fn().mockReturnValue(null),
fetchAllLiveQueries: jest.fn().mockReturnValue({ data: { data: { items: [] } } }),
}, },
timelines: createTGridMocks(), timelines: createTGridMocks(),
savedObjectsTagging: { 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 { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock'; 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 renderWithContext = (Element: React.ReactElement) => {
const mockTheme = getMockTheme({ eui: { euiColorLightestShade: '#F5F7FA' } }); const mockTheme = getMockTheme({ eui: { euiColorLightestShade: '#F5F7FA' } });
@ -38,7 +61,8 @@ describe('ResponseActionsForm', () => {
const { getByTestId, queryByTestId } = renderWithContext(<Component items={[]} />); const { getByTestId, queryByTestId } = renderWithContext(<Component items={[]} />);
expect(getByTestId('response-actions-form')); expect(getByTestId('response-actions-form'));
expect(getByTestId('response-actions-header')); 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); expect(queryByTestId('response-actions-list-item-0')).toEqual(null);
}); });
it('renders list of elements', async () => { 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 { map, reduce, upperFirst } from 'lodash';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { ResponseActionsWrapper } from './response_actions_wrapper';
import { FORM_ERRORS_TITLE } from '../../detections/components/rules/rule_actions_field/translations'; import { FORM_ERRORS_TITLE } from '../../detections/components/rules/rule_actions_field/translations';
import { ResponseActionsHeader } from './response_actions_header'; import { ResponseActionsHeader } from './response_actions_header';
import { ResponseActionsList } from './response_actions_list';
import type { ArrayItem, FormHook } from '../../shared_imports'; import type { ArrayItem, FormHook } from '../../shared_imports';
import { useSupportedResponseActionTypes } from './use_supported_response_action_types'; import { useSupportedResponseActionTypes } from './use_supported_response_action_types';
@ -44,7 +44,7 @@ export const ResponseActionsForm = ({
} }
return ( return (
<ResponseActionsList <ResponseActionsWrapper
items={items} items={items}
removeItem={removeItem} removeItem={removeItem}
supportedResponseActionTypes={supportedResponseActionTypes} supportedResponseActionTypes={supportedResponseActionTypes}

View file

@ -5,67 +5,59 @@
* 2.0. * 2.0.
*/ */
import React, { useCallback, useEffect, useRef, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui';
import type { ResponseActionType } from './get_supported_response_actions'; import { useParams } from 'react-router-dom';
import { ResponseActionAddButton } from './response_action_add_button';
import { OsqueryInvestigationGuidePanel } from './osquery/osquery_investigation_guide_panel';
import { useRule } from '../rule_management/logic';
import { ResponseActionTypeForm } from './response_action_type_form'; import { ResponseActionTypeForm } from './response_action_type_form';
import type { ArrayItem } from '../../shared_imports'; 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 { interface ResponseActionsListProps {
items: ArrayItem[]; items: ArrayItem[];
removeItem: (id: number) => void; removeItem: (id: number) => void;
addItem: () => void;
supportedResponseActionTypes: ResponseActionType[];
} }
const GhostFormField = () => <></>; const GhostFormField = () => <></>;
export const ResponseActionsList = React.memo( export const ResponseActionsList = React.memo<ResponseActionsListProps>(({ items, removeItem }) => {
({ items, removeItem, supportedResponseActionTypes, addItem }: ResponseActionsListProps) => { const { detailName: ruleId } = useParams<{ detailName: string }>();
const actionTypeIdRef = useRef<string | null>(null); const { data: rule } = useRule(ruleId);
const updateActionTypeId = useCallback((id) => {
actionTypeIdRef.current = id;
}, []);
const context = useFormContext(); const osqueryNoteQueries = useMemo(
const renderButton = useMemo(() => { () => (rule?.note ? getOsqueryQueriesFromNote(rule.note) : []),
return ( [rule?.note]
<ResponseActionAddButton );
supportedResponseActionTypes={supportedResponseActionTypes}
addActionType={addItem}
updateActionTypeId={updateActionTypeId}
/>
);
}, [addItem, updateActionTypeId, supportedResponseActionTypes]);
useEffect(() => { const context = useFormContext();
if (actionTypeIdRef.current) { const [formData] = useFormData();
const index = items.length - 1;
const path = `responseActions[${index}].actionTypeId`;
context.setFieldValue(path, actionTypeIdRef.current);
actionTypeIdRef.current = null;
}
}, [context, items.length]);
return ( const handleInvestigationGuideClick = useCallback(() => {
<div data-test-subj={'response-actions-list'}> const values = getResponseActionsFromNote(osqueryNoteQueries, formData.responseActions);
{items.map((actionItem, index) => { context.updateFieldValues(values);
return ( }, [context, formData?.responseActions, osqueryNoteQueries]);
<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} /> return (
</div> <div data-test-subj={'response-actions-list'}>
); {items.map((actionItem, index) => {
})} return (
<EuiSpacer size="m" /> <div key={actionItem.id} data-test-subj={`response-actions-list-item-${index}`}>
{renderButton} <EuiSpacer size="m" />
</div> <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'; 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. * 2.0.
*/ */
import React from 'react'; import React, { useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { EuiFlyout, EuiFlyoutFooter, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; import { EuiFlyout, EuiFlyoutFooter, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { useQueryClient } from '@tanstack/react-query';
import type { Ecs } from '../../../../common/ecs'; import type { Ecs } from '../../../../common/ecs';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { OsqueryEventDetailsFooter } from './osquery_flyout_footer'; import { OsqueryEventDetailsFooter } from './osquery_flyout_footer';
@ -19,11 +20,19 @@ const OsqueryActionWrapper = styled.div`
export interface OsqueryFlyoutProps { export interface OsqueryFlyoutProps {
agentId?: string; agentId?: string;
defaultValues?: {}; defaultValues?: {
alertIds?: string[];
query?: string;
ecs_mapping?: { [key: string]: {} };
queryField?: boolean;
};
onClose: () => void; onClose: () => void;
ecsData?: Ecs; 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> = ({ const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
agentId, agentId,
defaultValues, defaultValues,
@ -33,6 +42,13 @@ const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
const { const {
services: { osquery }, services: { osquery },
} = useKibana(); } = useKibana();
const queryClient = useQueryClient();
const invalidateQueries = useCallback(() => {
queryClient.invalidateQueries({
queryKey: [ACTIONS_QUERY_KEY, { alertId: defaultValues?.alertIds?.[0] }],
});
}, [defaultValues?.alertIds, queryClient]);
if (osquery?.OsqueryAction) { if (osquery?.OsqueryAction) {
return ( return (
@ -54,6 +70,7 @@ const OsqueryFlyoutComponent: React.FC<OsqueryFlyoutProps> = ({
formType="steps" formType="steps"
defaultValues={defaultValues} defaultValues={defaultValues}
ecsData={ecsData} ecsData={ecsData}
onSuccess={invalidateQueries}
/> />
</OsqueryActionWrapper> </OsqueryActionWrapper>
</EuiFlyoutBody> </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. * 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 type { RuleResponseAction } from '../../../../common/detection_engine/rule_response_actions/schemas';
import { RESPONSE_ACTION_TYPES } 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'; import type { SetupPlugins } from '../../../plugin_contract';
@ -15,31 +16,68 @@ interface ScheduleNotificationActions {
responseActions: RuleResponseAction[]; responseActions: RuleResponseAction[];
} }
interface IAlert { interface AlertsWithAgentType {
agent: { alerts: Ecs[];
id: string; 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 = ( export const scheduleNotificationResponseActions = (
{ signals, responseActions }: ScheduleNotificationActions, { signals, responseActions }: ScheduleNotificationActions,
osqueryCreateAction?: SetupPlugins['osquery']['osqueryCreateAction'] osqueryCreateAction?: SetupPlugins['osquery']['osqueryCreateAction']
) => { ) => {
const filteredAlerts = (signals as IAlert[]).filter((alert) => alert.agent?.id); const filteredAlerts = (signals as Ecs[]).filter((alert) => alert.agent?.id);
const agentIds = uniq(filteredAlerts.map((alert: IAlert) => alert.agent?.id));
const alertIds = map(filteredAlerts, '_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) { 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; const { savedQueryId, packId, queries, ecsMapping, ...rest } = responseAction.params;
return osqueryCreateAction({ if (!containsDynamicQueries) {
...rest, return osqueryCreateAction({
queries, ...rest,
ecs_mapping: ecsMapping, queries,
saved_query_id: savedQueryId, ecs_mapping: ecsMapping,
agent_ids: agentIds, saved_query_id: savedQueryId,
alert_ids: alertIds, 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/core-status-common-internal",
"@kbn/repo-info", "@kbn/repo-info",
"@kbn/storybook", "@kbn/storybook",
"@kbn/ecs",
"@kbn/cypress-config", "@kbn/cypress-config",
"@kbn/controls-plugin", "@kbn/controls-plugin",
"@kbn/shared-ux-utility", "@kbn/shared-ux-utility",