mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[EDR Workflows] Endpoint Insights UI (#202209)](https://github.com/elastic/kibana/pull/202209) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Konrad Szwarc","email":"konrad.szwarc@elastic.co"},"sourceCommit":{"committedDate":"2024-12-13T15:02:41Z","message":"[EDR Workflows] Endpoint Insights UI (#202209)\n\n## Description \nThis PR introduces the **Endpoint Insights generation functionality**.\nIt covers the happy path, providing a complete MVP flow of the feature.\n\nPlease make sure to enable feature flag(`defendInsights`) when testing\nlocally as well as enable defendInsights\n[here](efc0568e01/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts (L23)
).\n\n### Flow Overview \n- **Initial Load**: \n - Fetches already generated insights and ongoing scans on page load. \n - If ongoing scans are detected: \n - Polls periodically until no active scans remain. \n- Refetches insights to account for those generated during the scans.\n - Enables the **Scan** button once the above steps complete. \n\n- **Scan Trigger**: \n - On clicking the **Scan** button: \n - Calls an API to trigger new insight generation. \n- Repeats the polling and refetching process until no active scans are\nrunning.\n\n- **Insights Rendering**: \n - Displays generated insights as panels. \n- Clicking a panel redirects the user to the **Trusted Apps** page with\na prefilled modal.\n - On successful creation of a trusted app entry: \n - Redirects the user back to the **Endpoint Details** page. \n\n\nhttps://github.com/user-attachments/assets/72cb62a0-a66f-4a89-bbef-c41c53cdc3a2\n\n---------\n\nCo-authored-by: Joey F. Poon <joey.poon@elastic.co>\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"d12d07947fc6819ca7f9d2d8e39bc255b20b25cf","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:Defend Workflows","backport:prev-minor","v8.18.0"],"title":"[EDR Workflows] Endpoint Insights UI","number":202209,"url":"https://github.com/elastic/kibana/pull/202209","mergeCommit":{"message":"[EDR Workflows] Endpoint Insights UI (#202209)\n\n## Description \nThis PR introduces the **Endpoint Insights generation functionality**.\nIt covers the happy path, providing a complete MVP flow of the feature.\n\nPlease make sure to enable feature flag(`defendInsights`) when testing\nlocally as well as enable defendInsights\n[here](efc0568e01/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts (L23)
).\n\n### Flow Overview \n- **Initial Load**: \n - Fetches already generated insights and ongoing scans on page load. \n - If ongoing scans are detected: \n - Polls periodically until no active scans remain. \n- Refetches insights to account for those generated during the scans.\n - Enables the **Scan** button once the above steps complete. \n\n- **Scan Trigger**: \n - On clicking the **Scan** button: \n - Calls an API to trigger new insight generation. \n- Repeats the polling and refetching process until no active scans are\nrunning.\n\n- **Insights Rendering**: \n - Displays generated insights as panels. \n- Clicking a panel redirects the user to the **Trusted Apps** page with\na prefilled modal.\n - On successful creation of a trusted app entry: \n - Redirects the user back to the **Endpoint Details** page. \n\n\nhttps://github.com/user-attachments/assets/72cb62a0-a66f-4a89-bbef-c41c53cdc3a2\n\n---------\n\nCo-authored-by: Joey F. Poon <joey.poon@elastic.co>\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"d12d07947fc6819ca7f9d2d8e39bc255b20b25cf"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/202209","number":202209,"mergeCommit":{"message":"[EDR Workflows] Endpoint Insights UI (#202209)\n\n## Description \nThis PR introduces the **Endpoint Insights generation functionality**.\nIt covers the happy path, providing a complete MVP flow of the feature.\n\nPlease make sure to enable feature flag(`defendInsights`) when testing\nlocally as well as enable defendInsights\n[here](efc0568e01/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts (L23)
).\n\n### Flow Overview \n- **Initial Load**: \n - Fetches already generated insights and ongoing scans on page load. \n - If ongoing scans are detected: \n - Polls periodically until no active scans remain. \n- Refetches insights to account for those generated during the scans.\n - Enables the **Scan** button once the above steps complete. \n\n- **Scan Trigger**: \n - On clicking the **Scan** button: \n - Calls an API to trigger new insight generation. \n- Repeats the polling and refetching process until no active scans are\nrunning.\n\n- **Insights Rendering**: \n - Displays generated insights as panels. \n- Clicking a panel redirects the user to the **Trusted Apps** page with\na prefilled modal.\n - On successful creation of a trusted app entry: \n - Redirects the user back to the **Endpoint Details** page. \n\n\nhttps://github.com/user-attachments/assets/72cb62a0-a66f-4a89-bbef-c41c53cdc3a2\n\n---------\n\nCo-authored-by: Joey F. Poon <joey.poon@elastic.co>\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>","sha":"d12d07947fc6819ca7f9d2d8e39bc255b20b25cf"}},{"branch":"8.x","label":"v8.18.0","branchLabelMappingKey":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Konrad Szwarc <konrad.szwarc@elastic.co>
This commit is contained in:
parent
286ecfa81b
commit
3979f74e0a
24 changed files with 1093 additions and 243 deletions
|
@ -26,6 +26,9 @@ import {
|
|||
import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import { useIsMounted } from '@kbn/securitysolution-hook-utils';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMarkInsightAsRemediated } from '../hooks/use_mark_workflow_insight_as_remediated';
|
||||
import type { WorkflowInsightRouteState } from '../../../pages/endpoint_hosts/types';
|
||||
import { useUrlParams } from '../../../hooks/use_url_params';
|
||||
import { useIsFlyoutOpened } from '../hooks/use_is_flyout_opened';
|
||||
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
|
||||
|
@ -197,6 +200,11 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
links: { securitySolution },
|
||||
},
|
||||
} = useKibana().services;
|
||||
|
||||
const location = useLocation<WorkflowInsightRouteState>();
|
||||
const [sourceInsight, setSourceInsight] = useState<{ id: string; back_url: string } | null>(
|
||||
null
|
||||
);
|
||||
const getTestId = useTestIdGenerator(dataTestSubj);
|
||||
const toasts = useToasts();
|
||||
const isFlyoutOpened = useIsFlyoutOpened();
|
||||
|
@ -225,6 +233,10 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
error: internalSubmitError,
|
||||
} = useWithArtifactSubmitData(apiClient, formMode);
|
||||
|
||||
const { mutateAsync: markInsightAsRemediated } = useMarkInsightAsRemediated(
|
||||
sourceInsight?.back_url
|
||||
);
|
||||
|
||||
const isSubmittingData = useMemo(() => {
|
||||
return submitHandler ? externalIsSubmittingData : internalIsSubmittingData;
|
||||
}, [externalIsSubmittingData, internalIsSubmittingData, submitHandler]);
|
||||
|
@ -286,13 +298,23 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
);
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(result: ExceptionListItemSchema) => {
|
||||
async (result: ExceptionListItemSchema) => {
|
||||
toasts.addSuccess(
|
||||
isEditFlow
|
||||
? labels.flyoutEditSubmitSuccess(result)
|
||||
: labels.flyoutCreateSubmitSuccess(result)
|
||||
);
|
||||
|
||||
// Check if this artifact creation was opened from an endpoint insight
|
||||
try {
|
||||
if (sourceInsight?.id) {
|
||||
await markInsightAsRemediated({ insightId: sourceInsight.id });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setSourceInsight(null);
|
||||
}
|
||||
|
||||
if (isMounted()) {
|
||||
// Close the flyout
|
||||
// `undefined` will cause params to be dropped from url
|
||||
|
@ -301,7 +323,17 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
onSuccess();
|
||||
}
|
||||
},
|
||||
[isEditFlow, isMounted, labels, onSuccess, setUrlParams, toasts, urlParams]
|
||||
[
|
||||
isEditFlow,
|
||||
isMounted,
|
||||
labels,
|
||||
markInsightAsRemediated,
|
||||
onSuccess,
|
||||
setUrlParams,
|
||||
sourceInsight,
|
||||
toasts,
|
||||
urlParams,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSubmitClick = useCallback(() => {
|
||||
|
@ -357,6 +389,19 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
}
|
||||
}, [formState, confirmModalOnSuccess]);
|
||||
|
||||
// If this form was opened from an endpoint insight, prepopulate the form with the insight data
|
||||
useEffect(() => {
|
||||
if (location.state?.insight?.id && location.state?.insight?.item) {
|
||||
setSourceInsight({
|
||||
id: location.state.insight.id,
|
||||
back_url: location.state.insight.back_url,
|
||||
});
|
||||
setFormState({ isValid: true, item: location.state.insight.item });
|
||||
|
||||
location.state.insight = undefined;
|
||||
}
|
||||
}, [apiClient.listId, location.state, location.state?.insight]);
|
||||
|
||||
// If we don't have the actual Artifact data yet for edit (in initialization phase - ex. came in with an
|
||||
// ID in the url that was not in the list), then retrieve it now
|
||||
useEffect(() => {
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { useMutation } from '@tanstack/react-query';
|
||||
import { WORKFLOW_INSIGHTS } from '../../../pages/endpoint_hosts/view/translations';
|
||||
import type { SecurityWorkflowInsight } from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import { ActionType } from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables';
|
||||
import { WORKFLOW_INSIGHTS_UPDATE_ROUTE } from '../../../../../common/endpoint/constants';
|
||||
import { useKibana, useToasts } from '../../../../common/lib/kibana';
|
||||
|
||||
export const useMarkInsightAsRemediated = (backUrl?: string) => {
|
||||
const toasts = useToasts();
|
||||
const {
|
||||
application: { navigateToUrl },
|
||||
http,
|
||||
} = useKibana().services;
|
||||
return useMutation<SecurityWorkflowInsight, Error, { insightId: string }>(
|
||||
({ insightId }: { insightId: string }) =>
|
||||
http.put<SecurityWorkflowInsight>(
|
||||
resolvePathVariables(WORKFLOW_INSIGHTS_UPDATE_ROUTE, { insightId }),
|
||||
{
|
||||
version: '1',
|
||||
body: JSON.stringify({
|
||||
action: {
|
||||
type: ActionType.Remediated,
|
||||
},
|
||||
}),
|
||||
}
|
||||
),
|
||||
{
|
||||
onError: (err) => {
|
||||
toasts.addDanger({
|
||||
title: WORKFLOW_INSIGHTS.toasts.updateInsightError,
|
||||
text: err?.message,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (backUrl) return navigateToUrl(backUrl);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { DataViewBase } from '@kbn/es-query';
|
||||
import type { GetInfoResponse } from '@kbn/fleet-plugin/common';
|
||||
import type { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
|
||||
import type {
|
||||
AppLocation,
|
||||
EndpointPendingActions,
|
||||
|
@ -155,3 +156,11 @@ export interface TransformStatsResponse {
|
|||
count: number;
|
||||
transforms: TransformStats[];
|
||||
}
|
||||
|
||||
export interface WorkflowInsightRouteState {
|
||||
insight?: {
|
||||
back_url: string;
|
||||
id: string;
|
||||
item: CreateExceptionListItemSchema;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,31 +6,83 @@
|
|||
*/
|
||||
|
||||
import { EuiHorizontalRule, EuiAccordion, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import moment from 'moment';
|
||||
import { useFetchInsights } from '../../../hooks/insights/use_fetch_insights';
|
||||
import { useTriggerScan } from '../../../hooks/insights/use_trigger_scan';
|
||||
import { useFetchOngoingScans } from '../../../hooks/insights/use_fetch_ongoing_tasks';
|
||||
import { WorkflowInsightsResults } from './workflow_insights_results';
|
||||
import { WorkflowInsightsScanSection } from './workflow_insights_scan';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../../../common/hooks/use_experimental_features';
|
||||
import { WORKFLOW_INSIGHTS } from '../../../translations';
|
||||
|
||||
export const WorkflowInsights = () => {
|
||||
const isWorkflowInsightsEnabled = useIsExperimentalFeatureEnabled('defendInsights');
|
||||
interface WorkflowInsightsProps {
|
||||
endpointId: string;
|
||||
}
|
||||
|
||||
if (!isWorkflowInsightsEnabled) {
|
||||
return null;
|
||||
}
|
||||
export const WorkflowInsights = React.memo(({ endpointId }: WorkflowInsightsProps) => {
|
||||
const [isScanButtonDisabled, setIsScanButtonDisabled] = useState(true);
|
||||
const [scanCompleted, setIsScanCompleted] = useState(false);
|
||||
const [userTriggeredScan, setUserTriggeredScan] = useState(false);
|
||||
|
||||
const results = null;
|
||||
const disableScanButton = () => {
|
||||
setIsScanButtonDisabled(true);
|
||||
};
|
||||
|
||||
const renderLastResultsCaption = () => {
|
||||
if (!results) {
|
||||
const [setScanOngoing, setScanCompleted] = [
|
||||
() => setIsScanCompleted(false),
|
||||
() => setIsScanCompleted(true),
|
||||
];
|
||||
|
||||
const { data: insights, refetch: refetchInsights } = useFetchInsights({
|
||||
endpointId,
|
||||
onSuccess: setScanCompleted,
|
||||
});
|
||||
|
||||
const {
|
||||
data: ongoingScans,
|
||||
isLoading: isLoadingOngoingScans,
|
||||
refetch: refetchOngoingScans,
|
||||
} = useFetchOngoingScans({
|
||||
endpointId,
|
||||
isPolling: isScanButtonDisabled,
|
||||
onSuccess: refetchInsights,
|
||||
});
|
||||
|
||||
const { mutate: triggerScan } = useTriggerScan({
|
||||
onSuccess: refetchOngoingScans,
|
||||
onMutate: disableScanButton,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsScanButtonDisabled(!!ongoingScans?.length || isLoadingOngoingScans);
|
||||
}, [ongoingScans, isLoadingOngoingScans]);
|
||||
|
||||
const lastResultCaption = useMemo(() => {
|
||||
if (!insights?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestTimestamp = insights
|
||||
.map((insight) => moment.utc(insight['@timestamp']))
|
||||
.sort((a, b) => b.diff(a))[0];
|
||||
|
||||
return (
|
||||
<EuiText color={'subdued'} size={'xs'}>
|
||||
{WORKFLOW_INSIGHTS.titleRight}
|
||||
{`${WORKFLOW_INSIGHTS.titleRight} ${latestTimestamp.local().fromNow()}`}
|
||||
</EuiText>
|
||||
);
|
||||
};
|
||||
}, [insights]);
|
||||
|
||||
const onScanButtonClick = useCallback(
|
||||
({ actionTypeId, connectorId }: { actionTypeId: string; connectorId: string }) => {
|
||||
setScanOngoing();
|
||||
if (!userTriggeredScan) {
|
||||
setUserTriggeredScan(true);
|
||||
}
|
||||
triggerScan({ endpointId, actionTypeId, connectorId });
|
||||
},
|
||||
[setScanOngoing, userTriggeredScan, triggerScan, endpointId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -42,16 +94,25 @@ export const WorkflowInsights = () => {
|
|||
</EuiText>
|
||||
}
|
||||
initialIsOpen
|
||||
extraAction={renderLastResultsCaption()}
|
||||
extraAction={lastResultCaption}
|
||||
paddingSize={'none'}
|
||||
>
|
||||
<EuiSpacer size={'m'} />
|
||||
<WorkflowInsightsScanSection />
|
||||
<WorkflowInsightsScanSection
|
||||
isScanButtonDisabled={isScanButtonDisabled}
|
||||
onScanButtonClick={onScanButtonClick}
|
||||
/>
|
||||
<EuiSpacer size={'m'} />
|
||||
<WorkflowInsightsResults results={true} />
|
||||
<WorkflowInsightsResults
|
||||
results={insights}
|
||||
scanCompleted={scanCompleted && userTriggeredScan}
|
||||
endpointId={endpointId}
|
||||
/>
|
||||
<EuiHorizontalRule />
|
||||
</EuiAccordion>
|
||||
<EuiSpacer size="l" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
WorkflowInsights.displayName = 'WorkflowInsights';
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
|
@ -17,11 +17,22 @@ import {
|
|||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
import { WORKFLOW_INSIGHTS } from '../../../translations';
|
||||
|
||||
interface WorkflowInsightsResultsProps {
|
||||
results: boolean;
|
||||
results?: SecurityWorkflowInsight[];
|
||||
scanCompleted: boolean;
|
||||
endpointId: string;
|
||||
}
|
||||
import type { WorkflowInsightRouteState } from '../../../../types';
|
||||
import { getEndpointDetailsPath } from '../../../../../../common/routing';
|
||||
import { useKibana } from '../../../../../../../common/lib/kibana';
|
||||
import { APP_PATH, TRUSTED_APPS_PATH } from '../../../../../../../../common/constants';
|
||||
import type {
|
||||
ExceptionListRemediationType,
|
||||
SecurityWorkflowInsight,
|
||||
} from '../../../../../../../../common/endpoint/types/workflow_insights';
|
||||
|
||||
const CustomEuiCallOut = styled(EuiCallOut)`
|
||||
& .euiButtonIcon {
|
||||
|
@ -29,51 +40,126 @@ const CustomEuiCallOut = styled(EuiCallOut)`
|
|||
}
|
||||
`;
|
||||
|
||||
export const WorkflowInsightsResults = ({ results }: WorkflowInsightsResultsProps) => {
|
||||
const [showEmptyResultsCallout, setShowEmptyResultsCallout] = useState(true);
|
||||
export const WorkflowInsightsResults = ({
|
||||
results,
|
||||
scanCompleted,
|
||||
endpointId,
|
||||
}: WorkflowInsightsResultsProps) => {
|
||||
const [showEmptyResultsCallout, setShowEmptyResultsCallout] = useState(false);
|
||||
const hideEmptyStateCallout = () => setShowEmptyResultsCallout(false);
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiText size={'s'}>
|
||||
<h4>{WORKFLOW_INSIGHTS.issues.title}</h4>
|
||||
</EuiText>
|
||||
<EuiSpacer size={'s'} />
|
||||
<EuiPanel paddingSize="m" hasShadow={false} hasBorder>
|
||||
<EuiFlexGroup alignItems={'center'} gutterSize={'m'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="warning" size="l" color="warning" />
|
||||
</EuiFlexItem>
|
||||
const {
|
||||
application: { navigateToUrl },
|
||||
} = useKibana().services;
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
<EuiText size={'s'}>
|
||||
<strong>{'McAfee EndpointSecurity'}</strong>
|
||||
</EuiText>
|
||||
<EuiText size={'s'} color={'subdued'}>
|
||||
{'Add McAfee as a trusted application'}
|
||||
</EuiText>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
useEffect(() => {
|
||||
setShowEmptyResultsCallout(results?.length === 0 && scanCompleted);
|
||||
}, [results, scanCompleted]);
|
||||
|
||||
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
|
||||
<EuiButtonIcon
|
||||
iconType="popout"
|
||||
aria-label="External link"
|
||||
href="https://google.com"
|
||||
target="_blank"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
{showEmptyResultsCallout && (
|
||||
const openArtifactCreationPage = useCallback(
|
||||
({ remediation, id }: { remediation: ExceptionListRemediationType; id: string }) => {
|
||||
const getUrlBasedOnListId = (listId: string) => {
|
||||
switch (listId) {
|
||||
case ENDPOINT_ARTIFACT_LISTS.trustedApps.id:
|
||||
default:
|
||||
return TRUSTED_APPS_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
const url = `${APP_PATH}${getUrlBasedOnListId(remediation.list_id)}?show=create`;
|
||||
|
||||
const state: WorkflowInsightRouteState = {
|
||||
insight: {
|
||||
id,
|
||||
back_url: `${APP_PATH}${getEndpointDetailsPath({
|
||||
name: 'endpointDetails',
|
||||
selected_endpoint: endpointId,
|
||||
})}`,
|
||||
item: {
|
||||
comments: [],
|
||||
description: remediation.description,
|
||||
entries: remediation.entries,
|
||||
list_id: remediation.list_id,
|
||||
name: remediation.name,
|
||||
namespace_type: 'agnostic',
|
||||
tags: remediation.tags,
|
||||
type: 'simple',
|
||||
os_types: remediation.os_types,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
navigateToUrl(url, {
|
||||
state,
|
||||
});
|
||||
},
|
||||
[endpointId, navigateToUrl]
|
||||
);
|
||||
|
||||
const insights = useMemo(() => {
|
||||
if (showEmptyResultsCallout) {
|
||||
return (
|
||||
<CustomEuiCallOut onDismiss={hideEmptyStateCallout} color={'success'}>
|
||||
{WORKFLOW_INSIGHTS.issues.emptyResults}
|
||||
</CustomEuiCallOut>
|
||||
)}
|
||||
);
|
||||
} else if (results?.length) {
|
||||
return results.flatMap((insight, index) => {
|
||||
return (insight.remediation.exception_list_items ?? []).map((item) => {
|
||||
return (
|
||||
<EuiPanel paddingSize="m" hasShadow={false} hasBorder key={index}>
|
||||
<EuiFlexGroup alignItems={'center'} gutterSize={'m'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="warning" size="l" color="warning" />
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
<EuiText size={'s'}>
|
||||
<strong>{insight.value}</strong>
|
||||
</EuiText>
|
||||
<EuiText size={'s'} color={'subdued'}>
|
||||
{insight.message}
|
||||
</EuiText>
|
||||
<EuiText size={'xs'} color={'subdued'}>
|
||||
{item.entries[0].type === 'match' && item.entries[0].value}
|
||||
</EuiText>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
|
||||
<EuiButtonIcon
|
||||
aria-label={WORKFLOW_INSIGHTS.issues.insightRemediationButtonAriaLabel}
|
||||
iconType="popout"
|
||||
href={`${APP_PATH}${TRUSTED_APPS_PATH}?show=create`}
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
if (insight.id) {
|
||||
openArtifactCreationPage({ remediation: item, id: insight.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [openArtifactCreationPage, results, showEmptyResultsCallout]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showEmptyResultsCallout || results?.length ? (
|
||||
<>
|
||||
<EuiText size={'s'}>
|
||||
<h4>{WORKFLOW_INSIGHTS.issues.title}</h4>
|
||||
</EuiText>
|
||||
<EuiSpacer size={'s'} />
|
||||
</>
|
||||
) : null}
|
||||
{insights}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,7 +21,21 @@ import { useSpaceId } from '../../../../../../../common/hooks/use_space_id';
|
|||
import { WORKFLOW_INSIGHTS } from '../../../translations';
|
||||
import { useKibana } from '../../../../../../../common/lib/kibana';
|
||||
|
||||
export const WorkflowInsightsScanSection = () => {
|
||||
interface WorkflowInsightsScanSectionProps {
|
||||
isScanButtonDisabled: boolean;
|
||||
onScanButtonClick: ({
|
||||
actionTypeId,
|
||||
connectorId,
|
||||
}: {
|
||||
actionTypeId: string;
|
||||
connectorId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const WorkflowInsightsScanSection = ({
|
||||
isScanButtonDisabled,
|
||||
onScanButtonClick,
|
||||
}: WorkflowInsightsScanSectionProps) => {
|
||||
const CONNECTOR_ID_LOCAL_STORAGE_KEY = 'connectorId';
|
||||
|
||||
const spaceId = useSpaceId() ?? 'default';
|
||||
|
@ -54,17 +68,37 @@ export const WorkflowInsightsScanSection = () => {
|
|||
[aiConnectors, connectorId]
|
||||
);
|
||||
|
||||
const selectedConnectorActionTypeId = useMemo(() => {
|
||||
const selectedConnector = aiConnectors?.find((connector) => connector.id === connectorId);
|
||||
return selectedConnector?.actionTypeId;
|
||||
}, [aiConnectors, connectorId]);
|
||||
|
||||
// Render the scan button only if a connector is selected
|
||||
const renderScanButton = useMemo(() => {
|
||||
const scanButton = useMemo(() => {
|
||||
if (!connectorExists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton size="s">{WORKFLOW_INSIGHTS.scan.button}</EuiButton>
|
||||
<EuiButton
|
||||
size="s"
|
||||
isLoading={isScanButtonDisabled}
|
||||
onClick={() => {
|
||||
if (!connectorId || !selectedConnectorActionTypeId) return;
|
||||
onScanButtonClick({ connectorId, actionTypeId: selectedConnectorActionTypeId });
|
||||
}}
|
||||
>
|
||||
{isScanButtonDisabled ? WORKFLOW_INSIGHTS.scan.loading : WORKFLOW_INSIGHTS.scan.button}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}, [connectorExists]);
|
||||
}, [
|
||||
connectorExists,
|
||||
connectorId,
|
||||
isScanButtonDisabled,
|
||||
onScanButtonClick,
|
||||
selectedConnectorActionTypeId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EuiPanel paddingSize="m" hasShadow={false} hasBorder>
|
||||
|
@ -90,7 +124,7 @@ export const WorkflowInsightsScanSection = () => {
|
|||
selectedConnectorId={connectorId}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{renderScanButton}
|
||||
{scanButton}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { WorkflowInsights } from './components/insights/workflow_insights';
|
||||
import { isPolicyOutOfDate } from '../../utils';
|
||||
import { AgentStatus } from '../../../../../common/components/endpoint/agents/agent_status';
|
||||
|
@ -42,6 +43,7 @@ interface EndpointDetailsContentProps {
|
|||
|
||||
export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
|
||||
({ hostInfo, policyInfo }) => {
|
||||
const isWorkflowInsightsEnabled = useIsExperimentalFeatureEnabled('defendInsights');
|
||||
const queryParams = useEndpointSelector(uiQueryParams);
|
||||
const policyStatus = useMemo(
|
||||
() => hostInfo.metadata.Endpoint.policy.applied.status,
|
||||
|
@ -181,10 +183,9 @@ export const EndpointDetailsContent = memo<EndpointDetailsContentProps>(
|
|||
},
|
||||
];
|
||||
}, [hostInfo, policyInfo, missingPolicies, policyStatus, policyStatusClickHandler]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WorkflowInsights />
|
||||
{isWorkflowInsightsEnabled && <WorkflowInsights endpointId={hostInfo.metadata.agent.id} />}
|
||||
<EuiDescriptionList
|
||||
columnWidths={[1, 3]}
|
||||
compressed
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
import { ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION } from '@kbn/elastic-assistant-common';
|
||||
import { WORKFLOW_INSIGHTS } from '../../translations';
|
||||
import type { SecurityWorkflowInsight } from '../../../../../../../common/endpoint/types/workflow_insights';
|
||||
import { ActionType } from '../../../../../../../common/endpoint/types/workflow_insights';
|
||||
import { WORKFLOW_INSIGHTS_ROUTE } from '../../../../../../../common/endpoint/constants';
|
||||
import { useKibana, useToasts } from '../../../../../../common/lib/kibana';
|
||||
|
||||
interface UseFetchInsightsConfig {
|
||||
endpointId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const useFetchInsights = ({ endpointId, onSuccess }: UseFetchInsightsConfig) => {
|
||||
const { http } = useKibana().services;
|
||||
const toasts = useToasts();
|
||||
|
||||
return useQuery<SecurityWorkflowInsight[], Error, SecurityWorkflowInsight[]>(
|
||||
[`fetchInsights-${endpointId}`],
|
||||
async () => {
|
||||
try {
|
||||
const result = await http.get<SecurityWorkflowInsight[]>(WORKFLOW_INSIGHTS_ROUTE, {
|
||||
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
|
||||
query: {
|
||||
actionTypes: JSON.stringify([ActionType.Refreshed]),
|
||||
targetIds: JSON.stringify([endpointId]),
|
||||
},
|
||||
});
|
||||
onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
toasts.addDanger({
|
||||
title: WORKFLOW_INSIGHTS.toasts.fetchInsightsError,
|
||||
text: error?.message,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false, // We need full control over when to refetch
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DEFEND_INSIGHTS,
|
||||
type DefendInsightsResponse,
|
||||
DefendInsightStatusEnum,
|
||||
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { WORKFLOW_INSIGHTS } from '../../translations';
|
||||
import { useKibana, useToasts } from '../../../../../../common/lib/kibana';
|
||||
|
||||
interface UseFetchOngoingScansConfig {
|
||||
isPolling: boolean;
|
||||
endpointId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const useFetchOngoingScans = ({
|
||||
isPolling,
|
||||
endpointId,
|
||||
onSuccess,
|
||||
}: UseFetchOngoingScansConfig) => {
|
||||
const { http } = useKibana().services;
|
||||
const toasts = useToasts();
|
||||
|
||||
// Ref to track if polling was active in the previous render
|
||||
const wasPolling = useRef(isPolling);
|
||||
|
||||
useEffect(() => {
|
||||
// If polling was active and isPolling is false, it means the condition has been met and we can run onSuccess logic (i.e. refetch insights)
|
||||
if (wasPolling.current && !isPolling) {
|
||||
onSuccess();
|
||||
}
|
||||
wasPolling.current = isPolling;
|
||||
}, [isPolling, onSuccess]);
|
||||
|
||||
return useQuery<DefendInsightsResponse[], { body?: { error: string } }, DefendInsightsResponse[]>(
|
||||
[`fetchOngoingTasks-${endpointId}`],
|
||||
async () => {
|
||||
try {
|
||||
const response = await http.get<{ data: DefendInsightsResponse[] }>(DEFEND_INSIGHTS, {
|
||||
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
|
||||
query: {
|
||||
status: DefendInsightStatusEnum.running,
|
||||
endpoint_ids: [endpointId],
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toasts.addDanger({
|
||||
title: WORKFLOW_INSIGHTS.toasts.fetchPendingInsightsError,
|
||||
text: error?.body?.error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: isPolling ? 2000 : false,
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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 { useMutation } from '@tanstack/react-query';
|
||||
import type { DefendInsightsResponse } from '@kbn/elastic-assistant-common';
|
||||
import {
|
||||
DEFEND_INSIGHTS,
|
||||
DefendInsightTypeEnum,
|
||||
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields';
|
||||
import { useKibana, useToasts } from '../../../../../../common/lib/kibana';
|
||||
import { WORKFLOW_INSIGHTS } from '../../translations';
|
||||
|
||||
interface UseTriggerScanPayload {
|
||||
endpointId: string;
|
||||
connectorId: string;
|
||||
actionTypeId: string;
|
||||
}
|
||||
|
||||
interface UseTriggerScanConfig {
|
||||
onMutate: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const useTriggerScan = ({ onMutate, onSuccess }: UseTriggerScanConfig) => {
|
||||
const { http } = useKibana().services;
|
||||
const toasts = useToasts();
|
||||
|
||||
const { data: anonymizationFields } = useFetchAnonymizationFields();
|
||||
|
||||
return useMutation<DefendInsightsResponse, { body?: { error: string } }, UseTriggerScanPayload>(
|
||||
({ endpointId, connectorId, actionTypeId }: UseTriggerScanPayload) =>
|
||||
http.post<DefendInsightsResponse>(DEFEND_INSIGHTS, {
|
||||
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
|
||||
body: JSON.stringify({
|
||||
endpointIds: [endpointId],
|
||||
insightType: DefendInsightTypeEnum.incompatible_antivirus,
|
||||
anonymizationFields: anonymizationFields.data,
|
||||
replacements: {},
|
||||
subAction: 'invokeAI',
|
||||
apiConfig: {
|
||||
connectorId,
|
||||
actionTypeId,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
onMutate,
|
||||
onSuccess,
|
||||
onError: (err) => {
|
||||
toasts.addDanger({
|
||||
title: WORKFLOW_INSIGHTS.toasts.scanError,
|
||||
text: err.body?.error,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -18,7 +18,7 @@ export const WORKFLOW_INSIGHTS = {
|
|||
titleRight: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.extraAction',
|
||||
{
|
||||
defaultMessage: 'Last scans: ',
|
||||
defaultMessage: 'Last scans:',
|
||||
}
|
||||
),
|
||||
scan: {
|
||||
|
@ -28,6 +28,12 @@ export const WORKFLOW_INSIGHTS = {
|
|||
button: i18n.translate('xpack.securitySolution.endpointDetails.workflowInsights.scan.button', {
|
||||
defaultMessage: 'Scan',
|
||||
}),
|
||||
loading: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.scan.loading',
|
||||
{
|
||||
defaultMessage: 'Loading...',
|
||||
}
|
||||
),
|
||||
},
|
||||
issues: {
|
||||
title: i18n.translate('xpack.securitySolution.endpointDetails.workflowInsights.issues.title', {
|
||||
|
@ -39,6 +45,38 @@ export const WORKFLOW_INSIGHTS = {
|
|||
defaultMessage: 'No issues had been found',
|
||||
}
|
||||
),
|
||||
insightRemediationButtonAriaLabel: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.issues.insightRemediationButtonAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Create trusted app',
|
||||
}
|
||||
),
|
||||
},
|
||||
toasts: {
|
||||
scanError: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.toast.error',
|
||||
{
|
||||
defaultMessage: 'Failed to start scan',
|
||||
}
|
||||
),
|
||||
fetchInsightsError: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.toast.fetchInsightsError',
|
||||
{
|
||||
defaultMessage: 'Failed to fetch insights',
|
||||
}
|
||||
),
|
||||
fetchPendingInsightsError: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.toast.fetchPendingInsightsError',
|
||||
{
|
||||
defaultMessage: 'Failed to retrieve insights in the generation process',
|
||||
}
|
||||
),
|
||||
updateInsightError: i18n.translate(
|
||||
'xpack.securitySolution.endpointDetails.workflowInsights.toast.updateInsightError',
|
||||
{
|
||||
defaultMessage: 'Failed to mark insight as remediated',
|
||||
}
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ import { securityWorkflowInsightsService } from '../../../endpoint/services';
|
|||
import { getAnonymizedEvents } from './get_events';
|
||||
import { getDefendInsightsOutputParser } from './output_parsers';
|
||||
import { getDefendInsightsPrompt } from './prompts';
|
||||
import { buildWorkflowInsights } from './workflow_insights_builders';
|
||||
|
||||
export const DEFEND_INSIGHTS_TOOL_DESCRIPTION = 'Call this for Elastic Defend insights.';
|
||||
|
||||
|
@ -115,11 +114,7 @@ export const DEFEND_INSIGHTS_TOOL: AssistantTool = Object.freeze({
|
|||
});
|
||||
const insights: DefendInsight[] = result.records;
|
||||
|
||||
const workflowInsights = buildWorkflowInsights({
|
||||
defendInsights: insights,
|
||||
request,
|
||||
});
|
||||
workflowInsights.map(securityWorkflowInsightsService.create);
|
||||
await securityWorkflowInsightsService.createFromDefendInsights(insights, request);
|
||||
|
||||
return JSON.stringify({ eventsContextCount, insights }, null, 2);
|
||||
},
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
/*
|
||||
* 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 moment from 'moment';
|
||||
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
|
||||
import type { DefendInsight } from '@kbn/elastic-assistant-common';
|
||||
import type { BuildWorkflowInsightParams } from '.';
|
||||
import type { SecurityWorkflowInsight } from '../../../../../common/endpoint/types/workflow_insights';
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
Category,
|
||||
SourceType,
|
||||
TargetType,
|
||||
} from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import { SUPPORTED_HOST_OS_TYPE } from '../../../../../common/endpoint/constants';
|
||||
|
||||
export function buildIncompatibleAntivirusWorkflowInsights(
|
||||
params: BuildWorkflowInsightParams
|
||||
): SecurityWorkflowInsight[] {
|
||||
const currentTime = moment();
|
||||
const { defendInsights, request } = params;
|
||||
const { insightType, endpointIds, apiConfig } = request.body;
|
||||
return defendInsights.map((defendInsight: DefendInsight) => {
|
||||
const filePaths = (defendInsight.events ?? []).map((event) => event.value);
|
||||
return {
|
||||
'@timestamp': currentTime,
|
||||
// TODO add i18n support
|
||||
message: 'Incompatible antiviruses detected',
|
||||
category: Category.Endpoint,
|
||||
type: insightType,
|
||||
source: {
|
||||
type: SourceType.LlmConnector,
|
||||
id: apiConfig.connectorId,
|
||||
// TODO use actual time range when we add support
|
||||
data_range_start: currentTime,
|
||||
data_range_end: currentTime.clone().add(24, 'hours'),
|
||||
},
|
||||
target: {
|
||||
type: TargetType.Endpoint,
|
||||
ids: endpointIds,
|
||||
},
|
||||
action: {
|
||||
type: ActionType.Refreshed,
|
||||
timestamp: currentTime,
|
||||
},
|
||||
value: defendInsight.group,
|
||||
remediation: {
|
||||
exception_list_items: [
|
||||
{
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id,
|
||||
name: defendInsight.group,
|
||||
description: 'Suggested by Security Workflow Insights',
|
||||
entries: [
|
||||
{
|
||||
field: 'file.path.caseless',
|
||||
operator: 'included',
|
||||
type: 'match_any',
|
||||
value: filePaths,
|
||||
},
|
||||
],
|
||||
// TODO add per policy support
|
||||
tags: ['policy:all'],
|
||||
os_types: [
|
||||
// TODO pick this based on endpointIds
|
||||
SUPPORTED_HOST_OS_TYPE[0],
|
||||
SUPPORTED_HOST_OS_TYPE[1],
|
||||
SUPPORTED_HOST_OS_TYPE[2],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
notes: {
|
||||
llm_model: apiConfig.model ?? '',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
|
@ -66,8 +66,8 @@ describe('Get Insights Route Handler', () => {
|
|||
describe('with valid privileges', () => {
|
||||
it('should fetch insights and return them', async () => {
|
||||
const mockInsights = [
|
||||
{ _source: { id: 1, name: 'Insight 1' } },
|
||||
{ _source: { id: 2, name: 'Insight 2' } },
|
||||
{ _id: 1, _source: { name: 'Insight 1' } },
|
||||
{ _id: 2, _source: { name: 'Insight 2' } },
|
||||
];
|
||||
fetchMock.mockResolvedValue(mockInsights);
|
||||
|
||||
|
|
|
@ -70,7 +70,9 @@ export const getInsightsRouteHandler = (
|
|||
request.query as SearchParams
|
||||
);
|
||||
|
||||
const body = insightsResponse.flatMap((insight) => insight._source || []);
|
||||
const body = insightsResponse.flatMap((insight) =>
|
||||
insight._source ? { ...insight._source, id: insight._id } : []
|
||||
);
|
||||
|
||||
return response.ok({ body });
|
||||
} catch (e) {
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* 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 moment from 'moment';
|
||||
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
import type { DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
|
||||
import type { BuildWorkflowInsightParams } from '.';
|
||||
|
||||
import {
|
||||
Category,
|
||||
SourceType,
|
||||
TargetType,
|
||||
ActionType,
|
||||
} from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import { createMockEndpointAppContext } from '../../../mocks';
|
||||
import type { EndpointMetadataService } from '../../metadata';
|
||||
import { groupEndpointIdsByOS } from '../helpers';
|
||||
import { buildIncompatibleAntivirusWorkflowInsights } from './incompatible_antivirus';
|
||||
|
||||
jest.mock('../helpers', () => ({
|
||||
groupEndpointIdsByOS: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('buildIncompatibleAntivirusWorkflowInsights', () => {
|
||||
let params: BuildWorkflowInsightParams;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockEndpointAppContextService = createMockEndpointAppContext().service;
|
||||
mockEndpointAppContextService.getEndpointMetadataService = jest.fn().mockReturnValue({
|
||||
getMetadataForEndpoints: jest.fn(),
|
||||
});
|
||||
const endpointMetadataService =
|
||||
mockEndpointAppContextService.getEndpointMetadataService() as jest.Mocked<EndpointMetadataService>;
|
||||
|
||||
params = {
|
||||
defendInsights: [
|
||||
{
|
||||
group: 'AVGAntivirus',
|
||||
events: [
|
||||
{
|
||||
id: 'lqw5opMB9Ke6SNgnxRSZ',
|
||||
endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051',
|
||||
value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
request: {
|
||||
body: {
|
||||
insightType: 'incompatible_antivirus',
|
||||
endpointIds: ['endpoint-1'],
|
||||
apiConfig: {
|
||||
connectorId: 'connector-id-1',
|
||||
actionTypeId: 'action-type-id-1',
|
||||
model: 'model-1',
|
||||
},
|
||||
anonymizationFields: [],
|
||||
subAction: 'invokeAI',
|
||||
},
|
||||
} as unknown as KibanaRequest<unknown, unknown, DefendInsightsPostRequestBody>,
|
||||
endpointMetadataService,
|
||||
};
|
||||
|
||||
(groupEndpointIdsByOS as jest.Mock).mockResolvedValue({
|
||||
windows: ['endpoint-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly build workflow insights', async () => {
|
||||
const result = await buildIncompatibleAntivirusWorkflowInsights(params);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
'@timestamp': expect.any(moment),
|
||||
message: 'Incompatible antiviruses detected',
|
||||
category: Category.Endpoint,
|
||||
type: 'incompatible_antivirus',
|
||||
source: {
|
||||
type: SourceType.LlmConnector,
|
||||
id: 'connector-id-1',
|
||||
data_range_start: expect.any(moment),
|
||||
data_range_end: expect.any(moment),
|
||||
},
|
||||
target: {
|
||||
type: TargetType.Endpoint,
|
||||
ids: ['endpoint-1'],
|
||||
},
|
||||
action: {
|
||||
type: ActionType.Refreshed,
|
||||
timestamp: expect.any(moment),
|
||||
},
|
||||
value: 'AVGAntivirus',
|
||||
remediation: {
|
||||
exception_list_items: [
|
||||
{
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id,
|
||||
name: 'AVGAntivirus',
|
||||
description: 'Suggested by Security Workflow Insights',
|
||||
entries: [
|
||||
{
|
||||
field: 'process.executable.caseless',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value:
|
||||
'/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity',
|
||||
},
|
||||
],
|
||||
tags: ['policy:all'],
|
||||
os_types: ['windows'],
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
notes: {
|
||||
llm_model: 'model-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(groupEndpointIdsByOS).toHaveBeenCalledWith(
|
||||
['endpoint-1'],
|
||||
params.endpointMetadataService
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 moment from 'moment';
|
||||
|
||||
import type { DefendInsight } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
|
||||
import type { SecurityWorkflowInsight } from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import type { SupportedHostOsType } from '../../../../../common/endpoint/constants';
|
||||
import type { BuildWorkflowInsightParams } from '.';
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
Category,
|
||||
SourceType,
|
||||
TargetType,
|
||||
} from '../../../../../common/endpoint/types/workflow_insights';
|
||||
import { groupEndpointIdsByOS } from '../helpers';
|
||||
|
||||
export async function buildIncompatibleAntivirusWorkflowInsights(
|
||||
params: BuildWorkflowInsightParams
|
||||
): Promise<SecurityWorkflowInsight[]> {
|
||||
const currentTime = moment();
|
||||
const { defendInsights, request, endpointMetadataService } = params;
|
||||
const { insightType, endpointIds, apiConfig } = request.body;
|
||||
|
||||
const osEndpointIdsMap = await groupEndpointIdsByOS(endpointIds, endpointMetadataService);
|
||||
return defendInsights.flatMap((defendInsight: DefendInsight) => {
|
||||
const filePaths = Array.from(new Set((defendInsight.events ?? []).map((event) => event.value)));
|
||||
|
||||
return Object.keys(osEndpointIdsMap).flatMap((os) =>
|
||||
filePaths.map((filePath) => ({
|
||||
'@timestamp': currentTime,
|
||||
// TODO add i18n support
|
||||
message: 'Incompatible antiviruses detected',
|
||||
category: Category.Endpoint,
|
||||
type: insightType,
|
||||
source: {
|
||||
type: SourceType.LlmConnector,
|
||||
id: apiConfig.connectorId,
|
||||
// TODO use actual time range when we add support
|
||||
data_range_start: currentTime,
|
||||
data_range_end: currentTime.clone().add(24, 'hours'),
|
||||
},
|
||||
target: {
|
||||
type: TargetType.Endpoint,
|
||||
ids: endpointIds,
|
||||
},
|
||||
action: {
|
||||
type: ActionType.Refreshed,
|
||||
timestamp: currentTime,
|
||||
},
|
||||
value: defendInsight.group,
|
||||
remediation: {
|
||||
exception_list_items: [
|
||||
{
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id,
|
||||
name: defendInsight.group,
|
||||
description: 'Suggested by Security Workflow Insights',
|
||||
entries: [
|
||||
{
|
||||
field: 'process.executable.caseless',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value: filePath,
|
||||
},
|
||||
],
|
||||
// TODO add per policy support
|
||||
tags: ['policy:all'],
|
||||
os_types: [os as SupportedHostOsType],
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
notes: {
|
||||
llm_model: apiConfig.model ?? '',
|
||||
},
|
||||
},
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
|
@ -8,20 +8,24 @@
|
|||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
|
||||
import type { DefendInsight, DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import { DefendInsightType } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import type { SecurityWorkflowInsight } from '../../../../../common/endpoint/types/workflow_insights';
|
||||
|
||||
import { InvalidDefendInsightTypeError } from '../errors';
|
||||
import { InvalidDefendInsightTypeError } from '../../../../assistant/tools/defend_insights/errors';
|
||||
import type { EndpointMetadataService } from '../../metadata';
|
||||
import { buildIncompatibleAntivirusWorkflowInsights } from './incompatible_antivirus';
|
||||
|
||||
export interface BuildWorkflowInsightParams {
|
||||
defendInsights: DefendInsight[];
|
||||
request: KibanaRequest<unknown, unknown, DefendInsightsPostRequestBody>;
|
||||
endpointMetadataService: EndpointMetadataService;
|
||||
}
|
||||
|
||||
export function buildWorkflowInsights(
|
||||
params: BuildWorkflowInsightParams
|
||||
): SecurityWorkflowInsight[] {
|
||||
): Promise<SecurityWorkflowInsight[]> {
|
||||
if (params.request.body.insightType === DefendInsightType.Enum.incompatible_antivirus) {
|
||||
return buildIncompatibleAntivirusWorkflowInsights(params);
|
||||
}
|
|
@ -66,7 +66,7 @@ export const securityWorkflowInsightsFieldMap: FieldMap = {
|
|||
required: true,
|
||||
},
|
||||
// endpoint, policy, etc
|
||||
'target.id': {
|
||||
'target.ids': {
|
||||
type: 'keyword',
|
||||
array: true,
|
||||
required: true,
|
||||
|
|
|
@ -8,27 +8,33 @@
|
|||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter';
|
||||
import { DefendInsightType } from '@kbn/elastic-assistant-common';
|
||||
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
|
||||
import { kibanaPackageJson } from '@kbn/repo-info';
|
||||
|
||||
import type { HostMetadata } from '../../../../common/endpoint/types';
|
||||
import type { SearchParams } from '../../../../common/endpoint/types/workflow_insights';
|
||||
|
||||
import { buildEsQueryParams, createDatastream, createPipeline } from './helpers';
|
||||
import {
|
||||
DATA_STREAM_PREFIX,
|
||||
ActionType,
|
||||
Category,
|
||||
SourceType,
|
||||
} from '../../../../common/endpoint/types/workflow_insights';
|
||||
import type { EndpointMetadataService } from '../metadata';
|
||||
import {
|
||||
buildEsQueryParams,
|
||||
createDatastream,
|
||||
createPipeline,
|
||||
groupEndpointIdsByOS,
|
||||
} from './helpers';
|
||||
import {
|
||||
COMPONENT_TEMPLATE_NAME,
|
||||
DATA_STREAM_PREFIX,
|
||||
INDEX_TEMPLATE_NAME,
|
||||
INGEST_PIPELINE_NAME,
|
||||
TOTAL_FIELDS_LIMIT,
|
||||
} from './constants';
|
||||
import { securityWorkflowInsightsFieldMap } from './field_map_configurations';
|
||||
import {
|
||||
ActionType,
|
||||
Category,
|
||||
SourceType,
|
||||
TargetType,
|
||||
} from '../../../../common/endpoint/types/workflow_insights';
|
||||
import { createMockEndpointAppContext } from '../../mocks';
|
||||
|
||||
jest.mock('@kbn/data-stream-adapter', () => ({
|
||||
DataStreamSpacesAdapter: jest.fn().mockImplementation(() => ({
|
||||
|
@ -89,62 +95,102 @@ describe('helpers', () => {
|
|||
});
|
||||
|
||||
describe('buildEsQueryParams', () => {
|
||||
it('should build es query correct', () => {
|
||||
it('should build query with valid keys', () => {
|
||||
const searchParams: SearchParams = {
|
||||
size: 50,
|
||||
from: 50,
|
||||
ids: ['id1', 'id2'],
|
||||
categories: [Category.Endpoint],
|
||||
types: [DefendInsightType.Enum.incompatible_antivirus],
|
||||
types: ['incompatible_antivirus'],
|
||||
sourceTypes: [SourceType.LlmConnector],
|
||||
sourceIds: ['source-id1', 'source-id2'],
|
||||
targetTypes: [TargetType.Endpoint],
|
||||
targetIds: ['target-id1', 'target-id2'],
|
||||
actionTypes: [ActionType.Refreshed, ActionType.Remediated],
|
||||
sourceIds: ['source1'],
|
||||
targetIds: ['target1'],
|
||||
actionTypes: [ActionType.Refreshed],
|
||||
};
|
||||
const result = buildEsQueryParams(searchParams);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
terms: {
|
||||
_id: ['id1', 'id2'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
categories: ['endpoint'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
types: ['incompatible_antivirus'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'source.type': ['llm-connector'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'source.id': ['source-id1', 'source-id2'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'target.type': ['endpoint'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'target.id': ['target-id1', 'target-id2'],
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'action.type': ['refreshed', 'remediated'],
|
||||
},
|
||||
},
|
||||
{ terms: { _id: ['id1', 'id2'] } },
|
||||
{ terms: { categories: ['endpoint'] } },
|
||||
{ terms: { types: ['incompatible_antivirus'] } },
|
||||
{ nested: { path: 'source', query: { terms: { 'source.type': ['llm-connector'] } } } },
|
||||
{ nested: { path: 'source', query: { terms: { 'source.id': ['source1'] } } } },
|
||||
{ nested: { path: 'target', query: { terms: { 'target.ids': ['target1'] } } } },
|
||||
{ nested: { path: 'action', query: { terms: { 'action.type': ['refreshed'] } } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore invalid keys', () => {
|
||||
const searchParams = {
|
||||
invalidKey: 'invalidValue',
|
||||
ids: ['id1'],
|
||||
} as unknown as SearchParams;
|
||||
const result = buildEsQueryParams(searchParams);
|
||||
expect(result).toEqual([{ terms: { _id: ['id1'] } }]);
|
||||
});
|
||||
|
||||
it('should handle empty searchParams', () => {
|
||||
const searchParams: SearchParams = {};
|
||||
const result = buildEsQueryParams(searchParams);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle nested query for actionTypes', () => {
|
||||
const searchParams: SearchParams = {
|
||||
actionTypes: [ActionType.Refreshed],
|
||||
};
|
||||
const result = buildEsQueryParams(searchParams);
|
||||
expect(result).toEqual([
|
||||
{ nested: { path: 'action', query: { terms: { 'action.type': ['refreshed'] } } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle nested query for targetIds', () => {
|
||||
const searchParams: SearchParams = {
|
||||
targetIds: ['target1'],
|
||||
};
|
||||
const result = buildEsQueryParams(searchParams);
|
||||
expect(result).toEqual([
|
||||
{ nested: { path: 'target', query: { terms: { 'target.ids': ['target1'] } } } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupEndpointIdsByOS', () => {
|
||||
let endpointMetadataService: jest.Mocked<EndpointMetadataService>;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockEndpointAppContextService = createMockEndpointAppContext().service;
|
||||
mockEndpointAppContextService.getEndpointMetadataService = jest.fn().mockReturnValue({
|
||||
getMetadataForEndpoints: jest.fn(),
|
||||
});
|
||||
endpointMetadataService =
|
||||
mockEndpointAppContextService.getEndpointMetadataService() as jest.Mocked<EndpointMetadataService>;
|
||||
});
|
||||
|
||||
it('should correctly group endpoint IDs by OS type', async () => {
|
||||
const endpointIds = ['endpoint1', 'endpoint2', 'endpoint3'];
|
||||
const metadata = [
|
||||
{
|
||||
host: { os: { name: 'Windows' } },
|
||||
agent: { id: 'agent1' },
|
||||
},
|
||||
{
|
||||
host: { os: { name: 'Linux' } },
|
||||
agent: { id: 'agent2' },
|
||||
},
|
||||
{
|
||||
host: { os: { name: 'MacOS' } },
|
||||
agent: { id: 'agent3' },
|
||||
},
|
||||
] as HostMetadata[];
|
||||
|
||||
endpointMetadataService.getMetadataForEndpoints.mockResolvedValue(metadata);
|
||||
|
||||
const result = await groupEndpointIdsByOS(endpointIds, endpointMetadataService);
|
||||
|
||||
expect(result).toEqual({
|
||||
windows: ['agent1'],
|
||||
linux: ['agent2'],
|
||||
macos: ['agent3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,7 +13,9 @@ import type { ElasticsearchClient } from '@kbn/core/server';
|
|||
import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter';
|
||||
|
||||
import type { SearchParams } from '../../../../common/endpoint/types/workflow_insights';
|
||||
import type { SupportedHostOsType } from '../../../../common/endpoint/constants';
|
||||
|
||||
import type { EndpointMetadataService } from '../metadata';
|
||||
import {
|
||||
COMPONENT_TEMPLATE_NAME,
|
||||
DATA_STREAM_PREFIX,
|
||||
|
@ -76,14 +78,16 @@ const validKeys = new Set([
|
|||
'targetIds',
|
||||
'actionTypes',
|
||||
]);
|
||||
|
||||
const paramFieldMap = {
|
||||
ids: '_id',
|
||||
sourceTypes: 'source.type',
|
||||
sourceIds: 'source.id',
|
||||
targetTypes: 'target.type',
|
||||
targetIds: 'target.id',
|
||||
targetIds: 'target.ids',
|
||||
actionTypes: 'action.type',
|
||||
};
|
||||
const nestedKeys = new Set(['source', 'target', 'action']);
|
||||
export function buildEsQueryParams(searchParams: SearchParams): QueryDslQueryContainer[] {
|
||||
return Object.entries(searchParams).reduce((acc: object[], [k, v]) => {
|
||||
if (!validKeys.has(k)) {
|
||||
|
@ -91,8 +95,38 @@ export function buildEsQueryParams(searchParams: SearchParams): QueryDslQueryCon
|
|||
}
|
||||
|
||||
const paramKey = _get(paramFieldMap, k, k);
|
||||
const next = { terms: { [paramKey]: v } };
|
||||
|
||||
const topKey = paramKey.split('.')[0];
|
||||
if (nestedKeys.has(topKey)) {
|
||||
const nestedQuery = {
|
||||
nested: {
|
||||
path: topKey,
|
||||
query: {
|
||||
terms: { [paramKey]: v },
|
||||
},
|
||||
},
|
||||
};
|
||||
return [...acc, nestedQuery];
|
||||
}
|
||||
|
||||
const next = { terms: { [paramKey]: v } };
|
||||
return [...acc, next];
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function groupEndpointIdsByOS(
|
||||
endpointIds: string[],
|
||||
endpointMetadataService: EndpointMetadataService
|
||||
): Promise<Record<SupportedHostOsType, string[]>> {
|
||||
const metadata = await endpointMetadataService.getMetadataForEndpoints(endpointIds);
|
||||
return metadata.reduce<Record<string, string[]>>((acc, m) => {
|
||||
const os = m.host.os.name.toLowerCase() as SupportedHostOsType;
|
||||
if (!acc[os]) {
|
||||
acc[os] = [];
|
||||
}
|
||||
|
||||
acc[os].push(m.agent.id);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ import { merge } from 'lodash';
|
|||
import moment from 'moment';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
|
||||
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import type { ElasticsearchClient, KibanaRequest, Logger } from '@kbn/core/server';
|
||||
import type { DefendInsight, DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter';
|
||||
import { DefendInsightType } from '@kbn/elastic-assistant-common';
|
||||
|
@ -21,6 +22,7 @@ import type {
|
|||
SearchParams,
|
||||
SecurityWorkflowInsight,
|
||||
} from '../../../../common/endpoint/types/workflow_insights';
|
||||
import type { EndpointAppContextService } from '../../endpoint_app_context_services';
|
||||
|
||||
import {
|
||||
Category,
|
||||
|
@ -28,9 +30,11 @@ import {
|
|||
TargetType,
|
||||
ActionType,
|
||||
} from '../../../../common/endpoint/types/workflow_insights';
|
||||
import { createMockEndpointAppContext } from '../../mocks';
|
||||
import { createDatastream, createPipeline } from './helpers';
|
||||
import { securityWorkflowInsightsService } from '.';
|
||||
import { DATA_STREAM_NAME } from './constants';
|
||||
import { buildWorkflowInsights } from './builders';
|
||||
|
||||
jest.mock('./helpers', () => {
|
||||
const original = jest.requireActual('./helpers');
|
||||
|
@ -41,6 +45,14 @@ jest.mock('./helpers', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('./builders', () => {
|
||||
const original = jest.requireActual('./builders');
|
||||
return {
|
||||
...original,
|
||||
buildWorkflowInsights: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function getDefaultInsight(overrides?: Partial<SecurityWorkflowInsight>): SecurityWorkflowInsight {
|
||||
const defaultInsight = {
|
||||
'@timestamp': moment(),
|
||||
|
@ -95,10 +107,14 @@ function getDefaultInsight(overrides?: Partial<SecurityWorkflowInsight>): Securi
|
|||
describe('SecurityWorkflowInsightsService', () => {
|
||||
let logger: Logger;
|
||||
let esClient: ElasticsearchClient;
|
||||
let mockEndpointAppContextService: jest.Mocked<EndpointAppContextService>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = loggerMock.create();
|
||||
esClient = elasticsearchServiceMock.createElasticsearchClient();
|
||||
|
||||
mockEndpointAppContextService = createMockEndpointAppContext()
|
||||
.service as jest.Mocked<EndpointAppContextService>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -118,6 +134,7 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
kibanaVersion: kibanaPackageJson.version,
|
||||
logger,
|
||||
isFeatureEnabled: true,
|
||||
endpointContext: mockEndpointAppContextService,
|
||||
});
|
||||
|
||||
expect(createDatastreamMock).toHaveBeenCalledTimes(1);
|
||||
|
@ -134,6 +151,7 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
kibanaVersion: kibanaPackageJson.version,
|
||||
logger,
|
||||
isFeatureEnabled: true,
|
||||
endpointContext: mockEndpointAppContextService,
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
@ -158,6 +176,7 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
kibanaVersion: kibanaPackageJson.version,
|
||||
logger,
|
||||
isFeatureEnabled: true,
|
||||
endpointContext: mockEndpointAppContextService,
|
||||
});
|
||||
expect(createDatastreamMock).toHaveBeenCalledTimes(1);
|
||||
expect(createDatastreamMock).toHaveBeenCalledWith(kibanaPackageJson.version);
|
||||
|
@ -181,6 +200,7 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
kibanaVersion: kibanaPackageJson.version,
|
||||
logger,
|
||||
isFeatureEnabled: true,
|
||||
endpointContext: mockEndpointAppContextService,
|
||||
});
|
||||
|
||||
const createPipelineMock = createPipeline as jest.Mock;
|
||||
|
@ -195,6 +215,60 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('createFromDefendInsights', () => {
|
||||
it('should create workflow insights from defend insights', async () => {
|
||||
const isInitializedSpy = jest
|
||||
.spyOn(securityWorkflowInsightsService, 'isInitialized', 'get')
|
||||
.mockResolvedValueOnce([undefined, undefined]);
|
||||
|
||||
const defendInsights: DefendInsight[] = [
|
||||
{
|
||||
group: 'AVGAntivirus',
|
||||
events: [
|
||||
{
|
||||
id: 'lqw5opMB9Ke6SNgnxRSZ',
|
||||
endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051',
|
||||
value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const request = {} as KibanaRequest<unknown, unknown, DefendInsightsPostRequestBody>;
|
||||
const workflowInsights: SecurityWorkflowInsight[] = [getDefaultInsight()];
|
||||
|
||||
const buildWorkflowInsightsMock = buildWorkflowInsights as jest.Mock;
|
||||
buildWorkflowInsightsMock.mockResolvedValueOnce(workflowInsights);
|
||||
|
||||
const esClientIndexResp = {
|
||||
_index: DATA_STREAM_NAME,
|
||||
_id: '1',
|
||||
result: 'created' as const,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
failed: 0,
|
||||
},
|
||||
_version: 1,
|
||||
};
|
||||
jest.spyOn(esClient, 'index').mockResolvedValue(esClientIndexResp);
|
||||
await securityWorkflowInsightsService.start({ esClient });
|
||||
const result = await securityWorkflowInsightsService.createFromDefendInsights(
|
||||
defendInsights,
|
||||
request
|
||||
);
|
||||
|
||||
// twice since it calls securityWorkflowInsightsService.create internally
|
||||
expect(isInitializedSpy).toHaveBeenCalledTimes(2);
|
||||
expect(buildWorkflowInsightsMock).toHaveBeenCalledWith({
|
||||
defendInsights,
|
||||
request,
|
||||
endpointMetadataService: expect.any(Object),
|
||||
});
|
||||
expect(result).toEqual(workflowInsights.map(() => esClientIndexResp));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should index the doc correctly', async () => {
|
||||
const isInitializedSpy = jest
|
||||
|
@ -289,28 +363,53 @@ describe('SecurityWorkflowInsightsService', () => {
|
|||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'source.type': ['llm-connector'],
|
||||
nested: {
|
||||
path: 'source',
|
||||
query: {
|
||||
terms: {
|
||||
'source.type': ['llm-connector'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'source.id': ['source-id1', 'source-id2'],
|
||||
nested: {
|
||||
path: 'source',
|
||||
query: {
|
||||
terms: {
|
||||
'source.id': ['source-id1', 'source-id2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'target.type': ['endpoint'],
|
||||
nested: {
|
||||
path: 'target',
|
||||
query: {
|
||||
terms: {
|
||||
'target.type': ['endpoint'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'target.id': ['target-id1', 'target-id2'],
|
||||
nested: {
|
||||
path: 'target',
|
||||
query: {
|
||||
terms: {
|
||||
'target.ids': ['target-id1', 'target-id2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
'action.type': ['refreshed', 'remediated'],
|
||||
nested: {
|
||||
path: 'action',
|
||||
query: {
|
||||
terms: {
|
||||
'action.type': ['refreshed', 'remediated'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -12,17 +12,20 @@ import type {
|
|||
UpdateResponse,
|
||||
WriteResponseBase,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import type { ElasticsearchClient, KibanaRequest, Logger } from '@kbn/core/server';
|
||||
import type { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter';
|
||||
import type { DefendInsight, DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common';
|
||||
|
||||
import type {
|
||||
SearchParams,
|
||||
SecurityWorkflowInsight,
|
||||
} from '../../../../common/endpoint/types/workflow_insights';
|
||||
import type { EndpointAppContextService } from '../../endpoint_app_context_services';
|
||||
|
||||
import { SecurityWorkflowInsightsFailedInitialized } from './errors';
|
||||
import { buildEsQueryParams, createDatastream, createPipeline } from './helpers';
|
||||
import { DATA_STREAM_NAME } from './constants';
|
||||
import { buildWorkflowInsights } from './builders';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
|
@ -30,6 +33,7 @@ interface SetupInterface {
|
|||
kibanaVersion: string;
|
||||
logger: Logger;
|
||||
isFeatureEnabled: boolean;
|
||||
endpointContext: EndpointAppContextService;
|
||||
}
|
||||
|
||||
interface StartInterface {
|
||||
|
@ -42,6 +46,7 @@ class SecurityWorkflowInsightsService {
|
|||
private stop$ = new ReplaySubject<void>(1);
|
||||
private ds: DataStreamSpacesAdapter | undefined;
|
||||
private _esClient: ElasticsearchClient | undefined;
|
||||
private _endpointContext: EndpointAppContextService | undefined;
|
||||
private _logger: Logger | undefined;
|
||||
private _isInitialized: Promise<[void, void]> = firstValueFrom(
|
||||
combineLatest<[void, void]>([this.setup$, this.start$])
|
||||
|
@ -52,13 +57,14 @@ class SecurityWorkflowInsightsService {
|
|||
return this._isInitialized;
|
||||
}
|
||||
|
||||
public setup({ kibanaVersion, logger, isFeatureEnabled }: SetupInterface) {
|
||||
public setup({ kibanaVersion, logger, isFeatureEnabled, endpointContext }: SetupInterface) {
|
||||
this.isFeatureEnabled = isFeatureEnabled;
|
||||
if (!isFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger = logger;
|
||||
this._endpointContext = endpointContext;
|
||||
|
||||
try {
|
||||
this.ds = createDatastream(kibanaVersion);
|
||||
|
@ -85,14 +91,18 @@ class SecurityWorkflowInsightsService {
|
|||
esClient,
|
||||
pluginStop$: this.stop$,
|
||||
});
|
||||
await esClient.indices.createDataStream({ name: DATA_STREAM_NAME });
|
||||
} catch (err) {
|
||||
// ignore datastream already exists error
|
||||
if (err?.body?.error?.type === 'resource_already_exists_exception') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn(new SecurityWorkflowInsightsFailedInitialized(err.message).message);
|
||||
try {
|
||||
await esClient.indices.createDataStream({ name: DATA_STREAM_NAME });
|
||||
} catch (err) {
|
||||
if (err?.body?.error?.type === 'resource_already_exists_exception') {
|
||||
this.logger.debug(`Datastream ${DATA_STREAM_NAME} already exists`);
|
||||
} else {
|
||||
throw new SecurityWorkflowInsightsFailedInitialized(err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -108,6 +118,20 @@ class SecurityWorkflowInsightsService {
|
|||
this.stop$.complete();
|
||||
}
|
||||
|
||||
public async createFromDefendInsights(
|
||||
defendInsights: DefendInsight[],
|
||||
request: KibanaRequest<unknown, unknown, DefendInsightsPostRequestBody>
|
||||
): Promise<WriteResponseBase[]> {
|
||||
await this.isInitialized;
|
||||
|
||||
const workflowInsights = await buildWorkflowInsights({
|
||||
defendInsights,
|
||||
request,
|
||||
endpointMetadataService: this.endpointContext.getEndpointMetadataService(),
|
||||
});
|
||||
return Promise.all(workflowInsights.map((insight) => this.create(insight)));
|
||||
}
|
||||
|
||||
public async create(insight: SecurityWorkflowInsight): Promise<WriteResponseBase> {
|
||||
await this.isInitialized;
|
||||
|
||||
|
@ -185,6 +209,14 @@ class SecurityWorkflowInsightsService {
|
|||
|
||||
return this._logger;
|
||||
}
|
||||
|
||||
private get endpointContext(): EndpointAppContextService {
|
||||
if (!this._endpointContext) {
|
||||
throw new SecurityWorkflowInsightsFailedInitialized('no endpoint context found');
|
||||
}
|
||||
|
||||
return this._endpointContext;
|
||||
}
|
||||
}
|
||||
|
||||
export const securityWorkflowInsightsService = new SecurityWorkflowInsightsService();
|
||||
|
|
|
@ -530,6 +530,7 @@ export class Plugin implements ISecuritySolutionPlugin {
|
|||
kibanaVersion: pluginContext.env.packageInfo.version,
|
||||
logger: this.logger,
|
||||
isFeatureEnabled: config.experimentalFeatures.defendInsights,
|
||||
endpointContext: this.endpointContext.service,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue