[Osquery] Add Osquery results to Case (#139909)

This commit is contained in:
Tomasz Ciecierski 2022-09-16 09:50:35 +02:00 committed by GitHub
parent 77964bc0f7
commit b744e7cede
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 797 additions and 62 deletions

View file

@ -18,6 +18,8 @@
export {
CASES_URL,
SECURITY_SOLUTION_OWNER,
OBSERVABILITY_OWNER,
GENERAL_CASES_OWNER,
CREATE_CASES_CAPABILITY,
DELETE_CASES_CAPABILITY,
PUSH_CASES_CAPABILITY,
@ -25,7 +27,13 @@ export {
UPDATE_CASES_CAPABILITY,
} from './constants';
export { CommentType, CaseStatuses, getCasesFromAlertsUrl, throwErrors } from './api';
export {
CommentType,
CaseStatuses,
getCasesFromAlertsUrl,
throwErrors,
ExternalReferenceStorageType,
} from './api';
export type {
Case,

View file

@ -28,6 +28,7 @@ import type {
CasesMetricsRequest,
CasesStatusRequest,
CommentRequestAlertType,
CommentRequestExternalReferenceNoSOType,
CommentRequestPersistableStateType,
CommentRequestUserType,
} from '../common/api';
@ -152,7 +153,8 @@ export interface CasesUiStart {
export type SupportedCaseAttachment =
| CommentRequestAlertType
| CommentRequestUserType
| CommentRequestPersistableStateType;
| CommentRequestPersistableStateType
| CommentRequestExternalReferenceNoSOType;
export type CaseAttachments = SupportedCaseAttachment[];
export type CaseAttachmentWithoutOwner = DistributiveOmit<SupportedCaseAttachment, 'owner'>;

View file

@ -0,0 +1,42 @@
{
"attributes": {
"assignees": [],
"closed_at": null,
"closed_by": null,
"connector": {
"fields": [],
"name": "none",
"type": ".none"
},
"created_at": "2022-09-14T07:51:42.298Z",
"created_by": {
"email": null,
"full_name": null,
"username": "elastic"
},
"description": "Test Observability case",
"duration": null,
"external_service": null,
"owner": "observability",
"settings": {
"syncAlerts": false
},
"severity": "low",
"status": "open",
"tags": [
"obs"
],
"title": "Test Obs case",
"updated_at": null,
"updated_by": null
},
"coreMigrationVersion": "8.5.0",
"id": "127acc90-3402-11ed-a392-0f81f7d06b71",
"migrationVersion": {
"cases": "8.5.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-09-14T07:51:42.299Z",
"version": "WzEwMDIwLDFd"
}

View file

@ -0,0 +1,42 @@
{
"attributes": {
"assignees": [],
"closed_at": null,
"closed_by": null,
"connector": {
"fields": [],
"name": "none",
"type": ".none"
},
"created_at": "2022-09-14T08:29:25.376Z",
"created_by": {
"email": null,
"full_name": null,
"username": "elastic"
},
"description": "Test security description",
"duration": null,
"external_service": null,
"owner": "securitySolution",
"settings": {
"syncAlerts": true
},
"severity": "low",
"status": "open",
"tags": [
"security"
],
"title": "Test Security Case",
"updated_at": null,
"updated_by": null
},
"coreMigrationVersion": "8.5.0",
"id": "5760cad0-3407-11ed-b29d-758c6c3e798d",
"migrationVersion": {
"cases": "8.5.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-09-14T08:29:25.376Z",
"version": "WzE0OTk0LDFd"
}

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 { checkResults } from '../../tasks/live_query';
import { navigateTo } from '../../tasks/navigation';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { login } from '../../tasks/login';
import { ROLES } from '../../test';
describe('Add to Cases', () => {
describe('observability', () => {
before(() => {
runKbnArchiverScript(ArchiverMethod.LOAD, 'case_observability');
login(ROLES.soc_manager);
navigateTo('/app/osquery');
});
after(() => {
runKbnArchiverScript(ArchiverMethod.UNLOAD, 'case_observability');
});
it('should add result a case and not have add to timeline in result', () => {
cy.react('CustomItemAction', {
props: { index: 1 },
}).click();
cy.contains('Live query details');
cy.contains('Add to Case').click();
cy.contains('Select case');
cy.contains(/Select$/).click();
cy.contains('Test Obs case has been updated');
cy.visit('/app/observability/cases');
cy.contains('Test Obs case').click();
checkResults();
cy.contains('attached Osquery results');
cy.contains('SELECT * FROM users;');
cy.contains('View in Discover').should('exist');
cy.contains('View in Lens').should('exist');
cy.contains('Add to Case').should('not.exist');
cy.contains('Add to timeline investigation').should('not.exist');
});
});
describe('security', () => {
before(() => {
runKbnArchiverScript(ArchiverMethod.LOAD, 'case_security');
login(ROLES.soc_manager);
navigateTo('/app/osquery');
});
after(() => {
runKbnArchiverScript(ArchiverMethod.UNLOAD, 'case_security');
});
it('should add result a case and have add to timeline in result', () => {
cy.react('CustomItemAction', {
props: { index: 1 },
}).click();
cy.contains('Live query details');
cy.contains('Add to Case').click();
cy.contains('Select case');
cy.contains(/Select$/).click();
cy.contains('Test Security Case has been updated');
cy.visit('/app/security/cases');
cy.contains('Test Security Case').click();
checkResults();
cy.contains('attached Osquery results');
cy.contains('SELECT * FROM users;');
cy.contains('View in Discover').should('exist');
cy.contains('View in Lens').should('exist');
cy.contains('Add to Case').should('not.exist');
cy.contains('Add to timeline investigation').should('exist');
});
});
});

View file

@ -7,8 +7,8 @@
"githubTeam": "security-asset-management"
},
"kibanaVersion": "kibana",
"optionalPlugins": ["fleet", "home", "usageCollection", "lens", "telemetry"],
"requiredBundles": ["esUiShared", "fleet", "kibanaUtils", "kibanaReact", "lens"],
"optionalPlugins": ["fleet", "home", "usageCollection", "lens", "telemetry", "cases"],
"requiredBundles": ["esUiShared", "fleet", "kibanaUtils", "kibanaReact", "lens", "cases"],
"requiredPlugins": [
"actions",
"data",

View file

@ -8,20 +8,17 @@
import { useQuery } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { filter } from 'lodash';
import { useKibana } from '../common/lib/kibana';
import type { ESTermQuery } from '../../common/typed_json';
import { useErrorToast } from '../common/hooks/use_error_toast';
export interface LiveQueryDetailsArgs {
actionDetails: Record<string, string>;
id: string;
}
interface UseLiveQueryDetails {
actionId?: string;
isLive?: boolean;
filterQuery?: ESTermQuery | string;
skip?: boolean;
queryIds?: string[];
}
export interface LiveQueryDetailsItem {
@ -56,12 +53,13 @@ export const useLiveQueryDetails = ({
filterQuery,
isLive = false,
skip = false,
queryIds, // enable finding out specific queries only, eg. in cases
}: UseLiveQueryDetails) => {
const { http } = useKibana().services;
const setErrorToast = useErrorToast();
return useQuery<{ data: LiveQueryDetailsItem }, Error, LiveQueryDetailsItem>(
['liveQueries', { actionId, filterQuery }],
['liveQueries', { actionId, filterQuery, queryIds }],
() => http.get(`/api/osquery/live_queries/${actionId}`),
{
enabled: !skip && !!actionId,
@ -73,7 +71,17 @@ export const useLiveQueryDetails = ({
defaultMessage: 'Error while fetching action details',
}),
}),
select: (response) => response.data,
select: (response) => {
if (queryIds) {
const filteredQueries = filter(response.data.queries, (query) =>
queryIds.includes(query.action_id)
);
return { ...response.data, queries: filteredQueries };
}
return response.data;
},
refetchOnWindowFocus: false,
retryDelay: 5000,
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { CommentType, ExternalReferenceStorageType } from '@kbn/cases-plugin/common';
import { EuiButtonEmpty, EuiButtonIcon, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
import { useKibana } from '../common/lib/kibana';
const ADD_TO_CASE = i18n.translate(
'xpack.osquery.pack.queriesTable.addToCaseResultsActionAriaLabel',
{
defaultMessage: 'Add to Case',
}
);
interface IProps {
queryId: string;
agentIds?: string[];
actionId: string;
isIcon?: boolean;
isDisabled?: boolean;
iconProps?: Record<string, string>;
}
export const AddToCaseButton: React.FC<IProps> = ({
actionId,
agentIds = [],
queryId = '',
isIcon = false,
isDisabled,
iconProps,
}) => {
const { cases } = useKibana().services;
const casePermissions = cases.helpers.canUseCases();
const hasCasesPermissions =
casePermissions.read && casePermissions.update && casePermissions.push;
const selectCaseModal = cases.hooks.getUseCasesAddToExistingCaseModal({});
const handleClick = useCallback(() => {
const attachments: CaseAttachmentsWithoutOwner = [
{
type: CommentType.externalReference,
externalReferenceId: actionId,
externalReferenceStorage: {
type: ExternalReferenceStorageType.elasticSearchDoc,
},
externalReferenceAttachmentTypeId: 'osquery',
externalReferenceMetadata: { actionId, agentIds, queryId },
},
];
if (hasCasesPermissions) {
selectCaseModal.open({ attachments });
}
}, [actionId, agentIds, hasCasesPermissions, queryId, selectCaseModal]);
if (isIcon) {
return (
<EuiToolTip content={<EuiFlexItem>{ADD_TO_CASE}</EuiFlexItem>}>
<EuiButtonIcon
iconType={'casesApp'}
onClick={handleClick}
isDisabled={isDisabled || !hasCasesPermissions}
{...iconProps}
/>
</EuiToolTip>
);
}
return (
<EuiButtonEmpty
size="xs"
iconType="casesApp"
onClick={handleClick}
isDisabled={isDisabled || !hasCasesPermissions}
>
{ADD_TO_CASE}
</EuiButtonEmpty>
);
};

View file

@ -21,6 +21,7 @@ import { useForm as useHookForm, FormProvider } from 'react-hook-form';
import { isEmpty, map, find, pickBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { AddToTimelinePayload } from '../../timelines/get_add_to_timeline';
import type { SavedQuerySOFormData } from '../../saved_queries/form/use_saved_query_form';
import type {
EcsMappingFormField,
@ -40,6 +41,7 @@ import { LiveQueryQueryField } from './live_query_query_field';
import { AgentsTableField } from './agents_table_field';
import { PacksComboBoxField } from './packs_combobox_field';
import { savedQueryDataSerializer } from '../../saved_queries/form/use_saved_query_form';
import { AddToCaseButton } from '../../cases/add_to_cases_button';
export interface LiveQueryFormFields {
query?: string;
@ -105,7 +107,7 @@ interface LiveQueryFormProps {
formType?: FormType;
enabled?: boolean;
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => React.ReactElement;
}
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
@ -278,6 +280,26 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
);
const singleQueryDetails = useMemo(() => liveQueryDetails?.queries?.[0], [liveQueryDetails]);
const liveQueryActionId = useMemo(() => liveQueryDetails?.action_id, [liveQueryDetails]);
const addToCaseButton = useCallback(
(payload) => {
if (liveQueryActionId) {
return (
<AddToCaseButton
queryId={payload.queryId}
agentIds={agentIds}
actionId={liveQueryActionId}
isIcon={payload.isIcon}
isDisabled={payload.isDisabled}
/>
);
}
return <></>;
},
[agentIds, liveQueryActionId]
);
const resultsStepContent = useMemo(
() =>
@ -288,6 +310,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
endDate={singleQueryDetails?.expiration}
agentIds={singleQueryDetails?.agents}
addToTimeline={addToTimeline}
addToCase={addToCaseButton}
/>
) : null,
[
@ -296,6 +319,7 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
singleQueryDetails?.agents,
serializedData.ecs_mapping,
addToTimeline,
addToCaseButton,
]
);
@ -459,7 +483,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({
agentIds={agentIds}
// @ts-expect-error version string !+ string[]
data={liveQueryDetails?.queries ?? selectedPackData?.attributes?.queries}
addToCase={addToCaseButton}
addToTimeline={addToTimeline}
showResultsHeader
/>
</EuiFlexItem>
</>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { get } from 'lodash';
import { get, map } from 'lodash';
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import {
@ -34,6 +34,9 @@ import type {
import { DOCUMENT_FIELD_NAME as RECORDS_FIELD } from '@kbn/lens-plugin/common/constants';
import { FilterStateStore } from '@kbn/es-query';
import styled from 'styled-components';
import { SECURITY_APP_NAME } from '../../timelines/get_add_to_timeline';
import type { AddToTimelinePayload } from '../../timelines/get_add_to_timeline';
import { PackResultsHeader } from './pack_results_header';
import { Direction } from '../../../common/search_strategy';
import { removeMultilines } from '../../../common/utils/build_query/remove_multilines';
import { useKibana } from '../../common/lib/kibana';
@ -43,6 +46,8 @@ import type { PackItem } from '../../packs/types';
import type { LogsDataView } from '../../common/hooks/use_logs_data_view';
import { useLogsDataView } from '../../common/hooks/use_logs_data_view';
const CASES_OWNER: string[] = [];
const TruncateTooltipText = styled.div`
width: 100%;
@ -526,22 +531,39 @@ type PackQueryStatusItem = Partial<{
interface PackQueriesStatusTableProps {
agentIds?: string[];
queryId?: string;
actionId?: string;
data?: PackQueryStatusItem[];
startDate?: string;
expirationDate?: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => ReactElement;
addToCase?: ({
actionId,
isIcon,
isDisabled,
}: {
actionId?: string;
isIcon?: boolean;
isDisabled?: boolean;
}) => ReactElement;
showResultsHeader?: boolean;
}
const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = ({
actionId,
queryId,
agentIds,
data,
startDate,
expirationDate,
addToTimeline,
addToCase,
showResultsHeader,
}) => {
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, unknown>>({});
const { cases, timelines, appName } = useKibana().services;
const casePermissions = cases.helpers.canUseCases();
const CasesContext = cases.ui.getCasesContext();
const renderIDColumn = useCallback(
(id: string) => (
@ -595,7 +617,18 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
);
const renderLensResultsAction = useCallback((item) => <PackViewInLensAction item={item} />, []);
const handleAddToCase = useCallback(
(payload: { actionId: string; isIcon?: boolean }) =>
// eslint-disable-next-line react/display-name
() => {
if (addToCase) {
return addToCase({ actionId: payload.actionId, isIcon: payload?.isIcon });
}
return <></>;
},
[addToCase]
);
const getHandleErrorsToggle = useCallback(
(item) => () => {
setItemIdToExpandedRowMap((prevValue) => {
@ -614,6 +647,7 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
agentIds={agentIds}
failedAgentsCount={item?.failed ?? 0}
addToTimeline={addToTimeline}
addToCase={addToCase && handleAddToCase({ actionId: item.action_id })}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -623,7 +657,7 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
return itemIdToExpandedRowMapValues;
});
},
[agentIds, expirationDate, startDate, addToTimeline]
[startDate, expirationDate, agentIds, addToTimeline, addToCase, handleAddToCase]
);
const renderToggleResultsAction = useCallback(
@ -642,8 +676,28 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
const getItemId = useCallback((item: PackItem) => get(item, 'id'), []);
const columns = useMemo(
() => [
const columns = useMemo(() => {
const resultActions = [
{
render: renderDiscoverResultsAction,
},
{
render: renderLensResultsAction,
},
{
available: () => !!addToCase,
render: (item: { action_id: string }) =>
addToCase &&
addToCase({ actionId: item.action_id, isIcon: true, isDisabled: !item.action_id }),
},
{
available: () => addToTimeline && timelines && appName === SECURITY_APP_NAME,
render: (item: { action_id: string }) =>
addToTimeline && addToTimeline({ query: ['action_id', item.action_id], isIcon: true }),
},
];
return [
{
field: 'id',
name: i18n.translate('xpack.osquery.pack.queriesTable.idColumnTitle', {
@ -679,14 +733,7 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
defaultMessage: 'View results',
}),
width: '90px',
actions: [
{
render: renderDiscoverResultsAction,
},
{
render: renderLensResultsAction,
},
],
actions: resultActions,
},
{
id: 'actions',
@ -699,17 +746,20 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
},
],
},
],
[
renderIDColumn,
renderQueryColumn,
renderDocsColumn,
renderAgentsColumn,
renderDiscoverResultsAction,
renderLensResultsAction,
renderToggleResultsAction,
]
);
];
}, [
renderDiscoverResultsAction,
renderLensResultsAction,
renderIDColumn,
renderQueryColumn,
renderDocsColumn,
renderAgentsColumn,
renderToggleResultsAction,
addToCase,
addToTimeline,
timelines,
appName,
]);
const sorting = useMemo(
() => ({
@ -724,7 +774,7 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
useEffect(() => {
// reset the expanded row map when the data changes
setItemIdToExpandedRowMap({});
}, [actionId]);
}, [queryId, actionId]);
useEffect(() => {
if (
@ -737,15 +787,30 @@ const PackQueriesStatusTableComponent: React.FC<PackQueriesStatusTableProps> = (
}
}, [agentIds?.length, data, getHandleErrorsToggle, itemIdToExpandedRowMap]);
const queryIds = useMemo(
() =>
map(data, (query) => ({
value: query.action_id || '',
field: 'action_id',
})),
[data]
);
return (
<StyledEuiBasicTable
items={data ?? EMPTY_ARRAY}
itemId={getItemId}
columns={columns}
sorting={sorting}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable
/>
<CasesContext owner={CASES_OWNER} permissions={casePermissions}>
{showResultsHeader && (
<PackResultsHeader queryIds={queryIds} actionId={actionId} addToCase={addToCase} />
)}
<StyledEuiBasicTable
items={data ?? EMPTY_ARRAY}
itemId={getItemId}
columns={columns}
sorting={sorting}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable
/>
</CasesContext>
);
};

View file

@ -0,0 +1,66 @@
/*
* 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 { ReactElement } from 'react';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
interface PackResultsHeadersProps {
actionId?: string;
addToCase?: ({
isIcon,
iconProps,
}: {
isIcon: boolean;
iconProps: Record<string, string>;
}) => ReactElement;
queryIds: Array<{ value: string; field: string }>;
}
const StyledResultsHeading = styled(EuiFlexItem)`
padding-right: 20px;
border-right: 2px solid #d3dae6;
`;
const StyledIconsList = styled(EuiFlexItem)`
align-content: center;
justify-content: center;
padding-left: 10px;
`;
export const PackResultsHeader = ({ actionId, addToCase }: PackResultsHeadersProps) => (
<>
<EuiFlexGroup direction="row" gutterSize="m">
<StyledResultsHeading grow={false}>
<EuiText>
<h2>
<FormattedMessage
id="xpack.osquery.liveQueryActionResults.results"
defaultMessage="Results"
/>
</h2>
</EuiText>
</StyledResultsHeading>
<StyledIconsList grow={false}>
<span>
{actionId &&
addToCase &&
addToCase({
isIcon: true,
iconProps: {
color: 'text',
size: 'xs',
iconSize: 'l',
},
})}
</span>
</StyledIconsList>
</EuiFlexGroup>
<EuiSpacer size={'l'} />
</>
);

View file

@ -10,6 +10,7 @@ import { EuiCode, EuiLoadingContent, EuiEmptyPrompt } from '@elastic/eui';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AddToTimelinePayload } from '../timelines/get_add_to_timeline';
import type { EcsMappingSerialized } from '../packs/queries/ecs_mapping_editor_field';
import { LiveQueryForm } from './form';
import { useActionResultsPrivileges } from '../action_results/use_action_privileges';
@ -33,7 +34,7 @@ interface LiveQueryProps {
hideAgentsField?: boolean;
packId?: string;
agentSelection?: AgentSelection;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => React.ReactElement;
}
const LiveQueryComponent: React.FC<LiveQueryProps> = ({

View file

@ -19,6 +19,7 @@ import type {
OsqueryPluginStart,
StartPlugins,
AppPluginStartDependencies,
SetupPlugins,
} from './types';
import { OSQUERY_INTEGRATION_NAME, PLUGIN_NAME } from '../common';
import {
@ -30,7 +31,9 @@ import {
getLazyOsqueryAction,
getLazyLiveQueryField,
useIsOsqueryAvailableSimple,
getExternalReferenceAttachmentRegular,
} from './shared_components';
import type { ServicesWrapperProps } from './shared_components/services_wrapper';
export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> {
private kibanaVersion: string;
@ -40,7 +43,7 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
this.kibanaVersion = this.initializerContext.env.packageInfo.version;
}
public setup(core: CoreSetup): OsqueryPluginSetup {
public setup(core: CoreSetup, plugins: SetupPlugins): OsqueryPluginSetup {
const storage = this.storage;
const kibanaVersion = this.kibanaVersion;
// Register an application into the side navigation menu
@ -67,6 +70,17 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
},
});
core.getStartServices().then(([coreStart, depsStart]) => {
plugins.cases?.attachmentFramework.registerExternalReference(
getExternalReferenceAttachmentRegular({
...coreStart,
...depsStart,
storage,
kibanaVersion,
} as unknown as ServicesWrapperProps['services'])
);
});
// Return methods that should be available to other plugins
return {};
}

View file

@ -28,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
import { pagePathGetters } from '@kbn/fleet-plugin/public';
import type { AddToTimelinePayload } from '../timelines/get_add_to_timeline';
import type { ECSMapping } from '../../common/schemas/common';
import { useAllResults } from './use_all_results';
import type { ResultEdges } from '../../common/search_strategy';
@ -52,7 +53,8 @@ interface ResultsTableComponentProps {
ecsMapping?: ECSMapping;
endDate?: string;
startDate?: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => React.ReactElement;
addToCase?: ({ actionId }: { actionId?: string }) => React.ReactElement;
}
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
@ -62,6 +64,7 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
startDate,
endDate,
addToTimeline,
addToCase,
}) => {
const [isLive, setIsLive] = useState(true);
const { data: hasActionResultsPrivileges } = useActionResultsPrivileges();
@ -343,10 +346,11 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({
startDate={startDate}
/>
{addToTimeline && addToTimeline({ query: ['action_id', actionId] })}
{addToCase && addToCase({ actionId })}
</>
),
}),
[actionId, addToTimeline, endDate, startDate]
[actionId, addToCase, addToTimeline, endDate, startDate]
);
useEffect(

View file

@ -7,9 +7,10 @@
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AddToCaseButton } from '../../../cases/add_to_cases_button';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useLiveQueryDetails } from '../../../actions/use_live_query_details';
@ -52,6 +53,19 @@ const LiveQueryDetailsPageComponent = () => {
useLayoutEffect(() => {
setIsLive(() => !(data?.status === 'completed'));
}, [data?.status]);
const addToCaseButton = useCallback(
(payload) => (
<AddToCaseButton
queryId={payload.queryId}
actionId={actionId}
agentIds={data?.agents}
isIcon={payload.isIcon}
isDisabled={payload.isDisabled}
iconProps={payload.iconProps}
/>
),
[data?.agents, actionId]
);
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumnGrow={false}>
@ -61,6 +75,8 @@ const LiveQueryDetailsPageComponent = () => {
startDate={data?.['@timestamp']}
expirationDate={data?.expiration}
agentIds={data?.agents}
addToCase={addToCaseButton}
showResultsHeader
/>
</WithHeaderLayout>
);

View file

@ -8,11 +8,15 @@
import { EuiTabbedContent, EuiNotificationBadge } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { ReactElement } from 'react';
import { useKibana } from '../../../common/lib/kibana';
import type { AddToTimelinePayload } from '../../../timelines/get_add_to_timeline';
import type { ECSMapping } from '../../../../common/schemas/common';
import { ResultsTable } from '../../../results/results_table';
import { ActionResultsSummary } from '../../../action_results/action_results_summary';
const CASES_OWNER: string[] = [];
interface ResultTabsProps {
actionId: string;
agentIds?: string[];
@ -20,7 +24,8 @@ interface ResultTabsProps {
ecsMapping?: ECSMapping;
failedAgentsCount?: number;
endDate?: string;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => ReactElement;
addToCase?: ({ actionId }: { actionId?: string }) => ReactElement;
}
const ResultTabsComponent: React.FC<ResultTabsProps> = ({
@ -31,7 +36,12 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
failedAgentsCount,
startDate,
addToTimeline,
addToCase,
}) => {
const { cases } = useKibana().services;
const casePermissions = cases.helpers.canUseCases();
const CasesContext = cases.ui.getCasesContext();
const tabs = useMemo(
() => [
{
@ -45,6 +55,7 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
startDate={startDate}
endDate={endDate}
addToTimeline={addToTimeline}
addToCase={addToCase}
/>
),
},
@ -61,18 +72,29 @@ const ResultTabsComponent: React.FC<ResultTabsProps> = ({
) : null,
},
],
[actionId, agentIds, ecsMapping, startDate, endDate, addToTimeline, failedAgentsCount]
[
actionId,
agentIds,
ecsMapping,
startDate,
endDate,
addToTimeline,
addToCase,
failedAgentsCount,
]
);
return (
<EuiTabbedContent
// TODO: extend the EuiTabbedContent component to support EuiTabs props
// bottomBorder={false}
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
expand={false}
/>
<CasesContext owner={CASES_OWNER} permissions={casePermissions}>
<EuiTabbedContent
// TODO: extend the EuiTabbedContent component to support EuiTabs props
// bottomBorder={false}
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
expand={false}
/>
</CasesContext>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiAvatar } from '@elastic/eui';
import React from 'react';
import type { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { getLazyExternalContent } from './lazy_external_reference_content';
import type { ServicesWrapperProps } from '../services_wrapper';
import OsqueryLogo from '../../components/osquery_icon/osquery.svg';
export const getExternalReferenceAttachmentRegular = (
services: ServicesWrapperProps['services']
): ExternalReferenceAttachmentType => ({
id: 'osquery',
displayName: 'Osquery',
getAttachmentViewObject: () => ({
type: 'regular',
event: 'attached Osquery results',
timelineAvatar: <EuiAvatar name="osquery" color="subdued" iconType={OsqueryLogo} />,
// @ts-expect-error update types
children: getLazyExternalContent(services),
}),
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { IExternalReferenceMetaDataProps } from './lazy_external_reference_content';
import { PackQueriesAttachmentWrapper } from './pack_queries_attachment_wrapper';
const AttachmentContent = (props: IExternalReferenceMetaDataProps) => {
const { externalReferenceMetadata } = props;
return (
<EuiFlexGroup data-test-subj="osquery-attachment-content">
<EuiFlexItem>
<PackQueriesAttachmentWrapper
actionId={externalReferenceMetadata.actionId}
queryId={externalReferenceMetadata.queryId}
agentIds={externalReferenceMetadata.agentIds}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export { AttachmentContent as default };

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { lazy, Suspense } from 'react';
import { EuiCode, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { OsqueryIcon } from '../../components/osquery_icon';
import { useKibana } from '../../common/lib/kibana';
import type { ServicesWrapperProps } from '../services_wrapper';
import ServicesWrapper from '../services_wrapper';
import { PERMISSION_DENIED } from '../osquery_action/translations';
export interface IExternalReferenceMetaDataProps {
externalReferenceMetadata: {
actionId: string;
agentIds: string[];
queryId: string;
};
}
export const getLazyExternalContent =
// eslint-disable-next-line react/display-name
(services: ServicesWrapperProps['services']) => (props: IExternalReferenceMetaDataProps) => {
const {
services: {
application: {
capabilities: { osquery },
},
},
} = useKibana();
if (!osquery.read) {
return (
<EuiEmptyPrompt
icon={<OsqueryIcon />}
title={<h2>{PERMISSION_DENIED}</h2>}
titleSize="xs"
body={
<FormattedMessage
id="xpack.osquery.cases.permissionDenied"
defaultMessage=" To access these results, ask your administrator for {osquery} Kibana
privileges."
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{
osquery: <EuiCode>osquery</EuiCode>,
}}
/>
}
/>
);
}
const AttachmentContent = lazy(() => import('./external_references_content'));
return (
<Suspense fallback={null}>
<ServicesWrapper services={services}>
<AttachmentContent {...props} />
</ServicesWrapper>
</Suspense>
);
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useLayoutEffect, useState } from 'react';
import { useKibana } from '../../common/lib/kibana';
import { getAddToTimeline } from '../../timelines/get_add_to_timeline';
import { PackQueriesStatusTable } from '../../live_queries/form/pack_queries_status_table';
import { useLiveQueryDetails } from '../../actions/use_live_query_details';
interface PackQueriesAttachmentWrapperProps {
actionId?: string;
agentIds: string[];
queryId: string;
}
export const PackQueriesAttachmentWrapper = ({
actionId,
agentIds,
queryId,
}: PackQueriesAttachmentWrapperProps) => {
const {
services: { timelines, appName },
} = useKibana();
const [isLive, setIsLive] = useState(false);
const addToTimelineButton = getAddToTimeline(timelines, appName);
const { data } = useLiveQueryDetails({
actionId,
isLive,
...(queryId ? { queryIds: [queryId] } : {}),
});
useLayoutEffect(() => {
setIsLive(() => !(data?.status === 'completed'));
}, [data?.status]);
const addToTimeline = useCallback(
(payload) => {
if (!actionId || !addToTimelineButton) {
return <></>;
}
return addToTimelineButton(payload);
},
[actionId, addToTimelineButton]
);
return (
<PackQueriesStatusTable
actionId={actionId}
queryId={queryId}
data={data?.queries}
startDate={data?.['@timestamp']}
expirationDate={data?.expiration}
agentIds={agentIds}
addToTimeline={addToTimeline}
/>
);
};

View file

@ -8,3 +8,4 @@
export { getLazyOsqueryAction } from './lazy_osquery_action';
export { getLazyLiveQueryField } from './lazy_live_query_field';
export { useIsOsqueryAvailableSimple } from './osquery_action/use_is_osquery_available_simple';
export { getExternalReferenceAttachmentRegular } from './attachments/external_reference';

View file

@ -8,6 +8,7 @@
import { EuiLoadingContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { AddToTimelinePayload } from '../../timelines/get_add_to_timeline';
import {
AGENT_STATUS_ERROR,
EMPTY_PROMPT,
@ -25,7 +26,7 @@ export interface OsqueryActionProps {
defaultValues?: {};
formType: 'steps' | 'simple';
hideAgentsField?: boolean;
addToTimeline?: (payload: { query: [string, string]; isIcon?: true }) => React.ReactElement;
addToTimeline?: (payload: AddToTimelinePayload) => React.ReactElement;
}
const OsqueryActionComponent: React.FC<OsqueryActionProps> = ({

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import type { ServicesWrapperProps } from '../shared_components/services_wrapper';
const TimelineComponent = React.memo((props) => <EuiButtonEmpty {...props} size="xs" />);
TimelineComponent.displayName = 'TimelineComponent';
export interface AddToTimelinePayload {
query: [string, string];
isIcon?: true;
}
export const SECURITY_APP_NAME = 'Security';
export const getAddToTimeline = (
timelines: ServicesWrapperProps['services']['timelines'],
appName?: string
) => {
if (!timelines || appName !== SECURITY_APP_NAME) {
return;
}
const { getAddToTimelineButton } = timelines.getHoverActions();
return (payload: AddToTimelinePayload) => {
const {
query: [field, value],
isIcon,
} = payload;
const providerA = {
and: [],
enabled: true,
excluded: false,
id: value,
kqlQuery: '',
name: value,
queryMatch: {
field,
value,
operator: ':' as const,
},
};
return getAddToTimelineButton({
dataProvider: providerA,
field: value,
ownFocus: false,
...(isIcon ? { showTooltip: true } : { Component: TimelineComponent }),
});
};
};

View file

@ -16,10 +16,13 @@ import type {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { CasesUiStart, CasesUiSetup } from '@kbn/cases-plugin/public';
import type { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import type { getLazyLiveQueryField, getLazyOsqueryAction } from './shared_components';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface OsqueryPluginSetup {}
export interface OsqueryPluginStart {
OsqueryAction?: ReturnType<typeof getLazyOsqueryAction>;
LiveQueryField?: ReturnType<typeof getLazyLiveQueryField>;
@ -37,10 +40,14 @@ export interface StartPlugins {
lens?: LensPublicStart;
security: SecurityPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
cases: CasesUiStart;
timelines: TimelinesUIStart;
appName?: string;
}
export interface SetupPlugins {
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
cases?: CasesUiSetup;
}
export type StartServices = CoreStart & StartPlugins;

View file

@ -17,6 +17,8 @@
"feature": {
"discover": ["read"],
"infrastructure": ["read"],
"observabilityCases": ["all"],
"securitySolutionCases": ["all"],
"ml": ["all"],
"siem": ["all"],
"osquery": ["all"],

View file

@ -34,7 +34,7 @@ export const registerFeatures = (features: SetupPlugins['features']) => {
all: [],
read: [],
},
ui: ['write'],
ui: ['read', 'write'],
},
read: {
api: [`${PLUGIN_ID}-read`],

View file

@ -30,6 +30,7 @@
// optionalPlugins from ./kibana.json
{ "path": "../../../src/plugins/home/tsconfig.json" },
{ "path": "../cases/tsconfig.json" },
// requiredBundles from ./kibana.json
{ "path": "../../../src/plugins/es_ui_shared/tsconfig.json" },