[8.x] [EDR Workflows] Endpoint Insights UI (#202209) (#204236)

# 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:
Kibana Machine 2024-12-14 04:01:21 +11:00 committed by GitHub
parent 286ecfa81b
commit 3979f74e0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1093 additions and 243 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,7 +66,7 @@ export const securityWorkflowInsightsFieldMap: FieldMap = {
required: true,
},
// endpoint, policy, etc
'target.id': {
'target.ids': {
type: 'keyword',
array: true,
required: true,

View file

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

View file

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

View file

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

View file

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

View file

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