[Security Solution] [Endpoint] Add endpoint details activity log (#99795)

* WIP

add tabs for endpoint details

* fetch activity log for endpoint

this is work in progress with dummy data

* refactor to hold host details and activity log within endpointDetails

* api for fetching actions log

* add a selector for getting selected agent id

* use the new api to show actions log

* review changes

* move util function to common/utils

in order to use it in endpoint_hosts as well as in trusted _apps

review suggestion

* use util function to get API path

review suggestion

* sync url params with details active tab

review suggestion

* fix types due to merge commit

refs 3722552f73

* use AsyncResourseState type

review suggestions

* sort entries chronologically with recent at the top

* adjust icon sizes within entries to match mocks

* remove endpoint list paging stuff (not for now)

* fix import after sync with master

* make the search bar work (sort of)

this needs to be fleshed out in a later PR

* add tests to middleware for now

* use snake case for naming routes

review changes

* rename and use own relative time function

review change

* use euiTheme tokens

review change

* add a comment

review changes

* log errors to kibana log and unwind stack

review changes

* use FleetActionGenerator for mocking data

review changes

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ashokaditya 2021-06-03 09:22:49 +02:00 committed by GitHub
parent f367deca48
commit d4ecee6ba0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 868 additions and 149 deletions

View file

@ -32,3 +32,6 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`;
/** Host Isolation Routes */
export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`;
export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`;
/** Endpoint Actions Log Routes */
export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`;

View file

@ -20,3 +20,11 @@ export const HostIsolationRequestSchema = {
comment: schema.maybe(schema.string()),
}),
};
export const EndpointActionLogRequestSchema = {
// TODO improve when using pagination with query params
query: schema.object({}),
params: schema.object({
agent_id: schema.string(),
}),
};

View file

@ -8,10 +8,14 @@
import React from 'react';
import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react';
export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => {
export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({
date,
showRelativeTime = false,
}) => {
// If date is greater than or equal to 1h (ago), then show it as a date
// and if showRelativeTime is false
// else, show it as relative to "now"
return Date.now() - date.getTime() >= 3.6e6 ? (
return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? (
<>
<FormattedDate value={date} year="numeric" month="short" day="2-digit" />
{' @'}

View file

@ -25,7 +25,7 @@ import {
UpdateAlertStatusProps,
CasesFromAlertsResponse,
} from './types';
import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils';
import { resolvePathVariables } from '../../../../management/common/utils';
import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation';
/**

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { parseQueryFilterToKQL } from './utils';
import { parseQueryFilterToKQL, resolvePathVariables } from './utils';
describe('utils', () => {
const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`];
@ -39,4 +39,39 @@ describe('utils', () => {
);
});
});
describe('resolvePathVariables', () => {
it('should resolve defined variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe(
'/segment1/value1/segment2'
);
});
it('should not resolve undefined variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe(
'/segment1/{var1}/segment2'
);
});
it('should ignore unused variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe(
'/segment1/{var1}/segment2'
);
});
it('should replace multiple variable occurences', () => {
expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe(
'/value1/segment1/value1'
);
});
it('should replace multiple variables', () => {
const path = resolvePathVariables('/{var1}/segment1/{var2}', {
var1: 'value1',
var2: 'value2',
});
expect(path).toBe('/value1/segment1/value2');
});
});
});

View file

@ -19,3 +19,8 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly<string[]>
return kuery;
};
export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) =>
Object.keys(variables).reduce((acc, paramName) => {
return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName]));
}, path);

View file

@ -37,7 +37,6 @@ export interface ServerFailedToReturnEndpointDetails {
type: 'serverFailedToReturnEndpointDetails';
payload: ServerApiError;
}
export interface ServerReturnedEndpointPolicyResponse {
type: 'serverReturnedEndpointPolicyResponse';
payload: GetHostPolicyResponse;
@ -137,19 +136,24 @@ export interface ServerFailedToReturnEndpointsTotal {
payload: ServerApiError;
}
type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & {
export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & {
payload: HostIsolationRequestBody;
};
type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & {
export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & {
payload: EndpointState['isolationRequestState'];
};
export type EndpointDetailsActivityLogChanged = Action<'endpointDetailsActivityLogChanged'> & {
payload: EndpointState['endpointDetails']['activityLog'];
};
export type EndpointAction =
| ServerReturnedEndpointList
| ServerFailedToReturnEndpointList
| ServerReturnedEndpointDetails
| ServerFailedToReturnEndpointDetails
| EndpointDetailsActivityLogChanged
| ServerReturnedEndpointPolicyResponse
| ServerFailedToReturnEndpointPolicyResponse
| ServerReturnedPoliciesForOnboarding

View file

@ -0,0 +1,53 @@
/*
* 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 { Immutable } from '../../../../../common/endpoint/types';
import { DEFAULT_POLL_INTERVAL } from '../../../common/constants';
import { createUninitialisedResourceState } from '../../../state';
import { EndpointState } from '../types';
export const initialEndpointPageState = (): Immutable<EndpointState> => {
return {
hosts: [],
pageSize: 10,
pageIndex: 0,
total: 0,
loading: false,
error: undefined,
endpointDetails: {
activityLog: createUninitialisedResourceState(),
hostDetails: {
details: undefined,
detailsLoading: false,
detailsError: undefined,
},
},
policyResponse: undefined,
policyResponseLoading: false,
policyResponseError: undefined,
location: undefined,
policyItems: [],
selectedPolicyId: undefined,
policyItemsLoading: false,
endpointPackageInfo: undefined,
nonExistingPolicies: {},
agentPolicies: {},
endpointsExist: true,
patterns: [],
patternsError: undefined,
isAutoRefreshEnabled: true,
autoRefreshInterval: DEFAULT_POLL_INTERVAL,
agentsWithEndpointsTotal: 0,
agentsWithEndpointsTotalError: undefined,
endpointsTotal: 0,
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
hostStatus: undefined,
isolationRequestState: createUninitialisedResourceState(),
};
};

View file

@ -41,9 +41,16 @@ describe('EndpointList store concerns', () => {
total: 0,
loading: false,
error: undefined,
details: undefined,
detailsLoading: false,
detailsError: undefined,
endpointDetails: {
activityLog: {
type: 'UninitialisedResourceState',
},
hostDetails: {
details: undefined,
detailsLoading: false,
detailsError: undefined,
},
},
policyResponse: undefined,
policyResponseLoading: false,
policyResponseError: undefined,

View file

@ -18,6 +18,7 @@ import {
Immutable,
HostResultList,
HostIsolationResponse,
EndpointAction,
} from '../../../../../common/endpoint/types';
import { AppAction } from '../../../../common/store/actions';
import { mockEndpointResultList } from './mock_endpoint_result_list';
@ -25,8 +26,9 @@ import { listData } from './selectors';
import { EndpointState } from '../types';
import { endpointListReducer } from './reducer';
import { endpointMiddlewareFactory } from './middleware';
import { getEndpointListPath } from '../../../common/routing';
import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing';
import {
createLoadedResourceState,
FailedResourceState,
isFailedResourceState,
isLoadedResourceState,
@ -39,6 +41,7 @@ import {
hostIsolationRequestBodyMock,
hostIsolationResponseMock,
} from '../../../../common/lib/host_isolation/mocks';
import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
jest.mock('../../policy/store/services/ingest', () => ({
sendGetAgentConfigList: () => Promise.resolve({ items: [] }),
@ -192,4 +195,65 @@ describe('endpoint list middleware', () => {
expect(failedAction.error).toBe(apiError);
});
});
describe('handle ActivityLog State Change actions', () => {
const endpointList = getEndpointListApiResponse();
const search = getEndpointDetailsPath({
name: 'endpointDetails',
selected_endpoint: endpointList.hosts[0].metadata.agent.id,
});
const dispatchUserChangedUrl = () => {
dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/endpoints',
search: `?${search.split('?').pop()}`,
},
});
};
const fleetActionGenerator = new FleetActionGenerator(Math.random().toString());
const activityLog = [
fleetActionGenerator.generate({
agents: [endpointList.hosts[0].metadata.agent.id],
}),
];
const dispatchGetActivityLog = () => {
dispatch({
type: 'endpointDetailsActivityLogChanged',
payload: createLoadedResourceState(activityLog),
});
};
it('should set ActivityLog state to loading', async () => {
dispatchUserChangedUrl();
const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', {
validate(action) {
return isLoadingResourceState(action.payload);
},
});
const loadingDispatchedResponse = await loadingDispatched;
expect(loadingDispatchedResponse.payload.type).toEqual('LoadingResourceState');
});
it('should set ActivityLog state to loaded when fetching activity log is successful', async () => {
dispatchUserChangedUrl();
const loadedDispatched = waitForAction('endpointDetailsActivityLogChanged', {
validate(action) {
return isLoadedResourceState(action.payload);
},
});
dispatchGetActivityLog();
const loadedDispatchedResponse = await loadedDispatched;
const activityLogData = (loadedDispatchedResponse.payload as LoadedResourceState<
EndpointAction[]
>).data;
expect(activityLogData).toEqual(activityLog);
});
});
});

View file

@ -7,6 +7,7 @@
import { HttpStart } from 'kibana/public';
import {
EndpointAction,
HostInfo,
HostIsolationRequestBody,
HostIsolationResponse,
@ -18,6 +19,7 @@ import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../
import {
isOnEndpointPage,
hasSelectedEndpoint,
selectedAgent,
uiQueryParams,
listData,
endpointPackageInfo,
@ -27,6 +29,7 @@ import {
isTransformEnabled,
getIsIsolationRequestPending,
getCurrentIsolationRequestState,
getActivityLogData,
} from './selectors';
import { EndpointState, PolicyIds } from '../types';
import {
@ -37,12 +40,13 @@ import {
} from '../../policy/store/services/ingest';
import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common';
import {
ENDPOINT_ACTION_LOG_ROUTE,
HOST_METADATA_GET_API,
HOST_METADATA_LIST_API,
metadataCurrentIndexPattern,
} from '../../../../../common/endpoint/constants';
import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public';
import { resolvePathVariables } from '../../trusted_apps/service/utils';
import { resolvePathVariables } from '../../../common/utils';
import {
createFailedResourceState,
createLoadedResourceState,
@ -336,6 +340,29 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState
});
}
// call the activity log api
dispatch({
type: 'endpointDetailsActivityLogChanged',
// ts error to be fixed when AsyncResourceState is refactored (#830)
// @ts-expect-error
payload: createLoadingResourceState<EndpointAction[]>(getActivityLogData(getState())),
});
try {
const activityLog = await coreStart.http.get<EndpointAction[]>(
resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { agent_id: selectedAgent(getState()) })
);
dispatch({
type: 'endpointDetailsActivityLogChanged',
payload: createLoadedResourceState<EndpointAction[]>(activityLog),
});
} catch (error) {
dispatch({
type: 'endpointDetailsActivityLogChanged',
payload: createFailedResourceState<EndpointAction[]>(error.body ?? error),
});
}
// call the policy response api
try {
const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { EndpointDetailsActivityLogChanged } from './action';
import {
isOnEndpointPage,
hasSelectedEndpoint,
@ -12,52 +13,33 @@ import {
getCurrentIsolationRequestState,
} from './selectors';
import { EndpointState } from '../types';
import { initialEndpointPageState } from './builders';
import { AppAction } from '../../../../common/store/actions';
import { ImmutableReducer } from '../../../../common/store';
import { Immutable } from '../../../../../common/endpoint/types';
import { DEFAULT_POLL_INTERVAL } from '../../../common/constants';
import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state';
export const initialEndpointListState: Immutable<EndpointState> = {
hosts: [],
pageSize: 10,
pageIndex: 0,
total: 0,
loading: false,
error: undefined,
details: undefined,
detailsLoading: false,
detailsError: undefined,
policyResponse: undefined,
policyResponseLoading: false,
policyResponseError: undefined,
location: undefined,
policyItems: [],
selectedPolicyId: undefined,
policyItemsLoading: false,
endpointPackageInfo: undefined,
nonExistingPolicies: {},
agentPolicies: {},
endpointsExist: true,
patterns: [],
patternsError: undefined,
isAutoRefreshEnabled: true,
autoRefreshInterval: DEFAULT_POLL_INTERVAL,
agentsWithEndpointsTotal: 0,
agentsWithEndpointsTotalError: undefined,
endpointsTotal: 0,
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
hostStatus: undefined,
isolationRequestState: createUninitialisedResourceState(),
type StateReducer = ImmutableReducer<EndpointState, AppAction>;
type CaseReducer<T extends AppAction> = (
state: Immutable<EndpointState>,
action: Immutable<T>
) => Immutable<EndpointState>;
const handleEndpointDetailsActivityLogChanged: CaseReducer<EndpointDetailsActivityLogChanged> = (
state,
action
) => {
return {
...state!,
endpointDetails: {
...state.endpointDetails!,
activityLog: action.payload,
},
};
};
/* eslint-disable-next-line complexity */
export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
state = initialEndpointListState,
action
) => {
export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => {
if (action.type === 'serverReturnedEndpointList') {
const {
hosts,
@ -115,18 +97,32 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
} else if (action.type === 'serverReturnedEndpointDetails') {
return {
...state,
details: action.payload.metadata,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
details: action.payload.metadata,
detailsLoading: false,
detailsError: undefined,
},
},
policyVersionInfo: action.payload.policy_info,
hostStatus: action.payload.host_status,
detailsLoading: false,
detailsError: undefined,
};
} else if (action.type === 'serverFailedToReturnEndpointDetails') {
return {
...state,
detailsError: action.payload,
detailsLoading: false,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
detailsError: action.payload,
detailsLoading: false,
},
},
};
} else if (action.type === 'endpointDetailsActivityLogChanged') {
return handleEndpointDetailsActivityLogChanged(state, action);
} else if (action.type === 'serverReturnedPoliciesForOnboarding') {
return {
...state,
@ -221,7 +217,6 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
const stateUpdates: Partial<EndpointState> = {
location: action.payload,
error: undefined,
detailsError: undefined,
policyResponseError: undefined,
};
@ -239,6 +234,13 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
detailsError: undefined,
},
},
loading: true,
policyItemsLoading: true,
};
@ -249,6 +251,14 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
detailsLoading: true,
detailsError: undefined,
},
},
detailsLoading: true,
policyResponseLoading: true,
};
@ -257,8 +267,15 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
detailsLoading: true,
detailsError: undefined,
},
},
loading: true,
detailsLoading: true,
policyResponseLoading: true,
policyItemsLoading: true,
};
@ -268,6 +285,13 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
return {
...state,
...stateUpdates,
endpointDetails: {
...state.endpointDetails,
hostDetails: {
...state.endpointDetails.hostDetails,
detailsError: undefined,
},
},
endpointsExist: true,
};
}

View file

@ -45,11 +45,16 @@ export const listLoading = (state: Immutable<EndpointState>): boolean => state.l
export const listError = (state: Immutable<EndpointState>) => state.error;
export const detailsData = (state: Immutable<EndpointState>) => state.details;
export const detailsData = (state: Immutable<EndpointState>) =>
state.endpointDetails.hostDetails.details;
export const detailsLoading = (state: Immutable<EndpointState>): boolean => state.detailsLoading;
export const detailsLoading = (state: Immutable<EndpointState>): boolean =>
state.endpointDetails.hostDetails.detailsLoading;
export const detailsError = (state: Immutable<EndpointState>) => state.detailsError;
export const detailsError = (
state: Immutable<EndpointState>
): EndpointState['endpointDetails']['hostDetails']['detailsError'] =>
state.endpointDetails.hostDetails.detailsError;
export const policyItems = (state: Immutable<EndpointState>) => state.policyItems;
@ -209,7 +214,12 @@ export const uiQueryParams: (
if (value !== undefined) {
if (key === 'show') {
if (value === 'policy_response' || value === 'details' || value === 'isolate') {
if (
value === 'policy_response' ||
value === 'details' ||
value === 'activity_log' ||
value === 'isolate'
) {
data[key] = value;
}
} else {
@ -240,6 +250,19 @@ export const showView: (
return searchParams.show ?? 'details';
});
/**
* Returns the selected endpoint's elastic agent Id
* used for fetching endpoint actions log
*/
export const selectedAgent = (state: Immutable<EndpointState>): string => {
const hostList = state.hosts;
const { selected_endpoint: selectedEndpoint } = uiQueryParams(state);
return (
hostList.find((host) => host.metadata.agent.id === selectedEndpoint)?.metadata.elastic.agent
.id || ''
);
};
/**
* Returns the Host Status which is connected the fleet agent
*/
@ -331,3 +354,27 @@ export const getIsolationRequestError: (
return isolateHost.error;
}
});
export const getActivityLogData = (
state: Immutable<EndpointState>
): Immutable<EndpointState['endpointDetails']['activityLog']> => state.endpointDetails.activityLog;
export const getActivityLogRequestLoading: (
state: Immutable<EndpointState>
) => boolean = createSelector(getActivityLogData, (activityLog) =>
isLoadingResourceState(activityLog)
);
export const getActivityLogRequestLoaded: (
state: Immutable<EndpointState>
) => boolean = createSelector(getActivityLogData, (activityLog) =>
isLoadedResourceState(activityLog)
);
export const getActivityLogError: (
state: Immutable<EndpointState>
) => ServerApiError | undefined = createSelector(getActivityLogData, (activityLog) => {
if (isFailedResourceState(activityLog)) {
return activityLog.error;
}
});

View file

@ -14,6 +14,7 @@ import {
PolicyData,
MetadataQueryStrategyVersions,
HostStatus,
EndpointAction,
HostIsolationResponse,
} from '../../../../common/endpoint/types';
import { ServerApiError } from '../../../common/types';
@ -34,12 +35,17 @@ export interface EndpointState {
loading: boolean;
/** api error from retrieving host list */
error?: ServerApiError;
/** details data for a specific host */
details?: Immutable<HostMetadata>;
/** details page is retrieving data */
detailsLoading: boolean;
/** api error from retrieving host details */
detailsError?: ServerApiError;
endpointDetails: {
activityLog: AsyncResourceState<EndpointAction[]>;
hostDetails: {
/** details data for a specific host */
details?: Immutable<HostMetadata>;
/** details page is retrieving data */
detailsLoading: boolean;
/** api error from retrieving host details */
detailsError?: ServerApiError;
};
};
/** Holds the Policy Response for the Host currently being displayed in the details */
policyResponse?: HostPolicyResponse;
/** policyResponse is being retrieved */
@ -108,7 +114,7 @@ export interface EndpointIndexUIQueryParams {
/** Which page to show */
page_index?: string;
/** show the policy response or host details */
show?: 'policy_response' | 'details' | 'isolate';
show?: 'policy_response' | 'activity_log' | 'details' | 'isolate';
/** Query text from search bar*/
admin_query?: string;
}

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import { EndpointIndexUIQueryParams } from '../../../types';
export enum EndpointDetailsTabsTypes {
overview = 'overview',
activityLog = 'activity_log',
}
export type EndpointDetailsTabsId =
| EndpointDetailsTabsTypes.overview
| EndpointDetailsTabsTypes.activityLog;
interface EndpointDetailsTabs {
id: string;
name: string;
content: JSX.Element;
}
const StyledEuiTabbedContent = styled(EuiTabbedContent)`
overflow: hidden;
padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl};
> [role='tabpanel'] {
height: 100%;
padding-right: 12px;
overflow: hidden;
overflow-y: auto;
::-webkit-scrollbar {
-webkit-appearance: none;
width: 4px;
}
::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.5);
-webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
}
`;
export const EndpointDetailsFlyoutTabs = memo(
({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => {
const [selectedTabId, setSelectedTabId] = useState<EndpointDetailsTabsId>(() => {
return show === 'details'
? EndpointDetailsTabsTypes.overview
: EndpointDetailsTabsTypes.activityLog;
});
const handleTabClick = useCallback(
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId),
[setSelectedTabId]
);
const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [
tabs,
selectedTabId,
]);
return (
<StyledEuiTabbedContent
data-test-subj="endpointDetailsTabs"
tabs={tabs}
selectedTab={selectedTab}
onTabClick={handleTabClick}
key="endpoint-details-tabs"
/>
);
}
);
EndpointDetailsFlyoutTabs.displayName = 'EndpointDetailsFlyoutTabs';

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiAvatar, EuiComment, EuiText } from '@elastic/eui';
import { Immutable, EndpointAction } from '../../../../../../../common/endpoint/types';
import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time';
import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme';
export const LogEntry = memo(
({ endpointAction }: { endpointAction: Immutable<EndpointAction> }) => {
const euiTheme = useEuiTheme();
const isIsolated = endpointAction?.data.command === 'isolate';
// do this better when we can distinguish between endpoint events vs user events
const iconType = endpointAction.user_id === 'sys' ? 'dot' : isIsolated ? 'lock' : 'lockOpen';
const commentType = endpointAction.user_id === 'sys' ? 'update' : 'regular';
const timelineIcon = (
<EuiAvatar
name="Timeline Icon"
size={endpointAction.user_id === 'sys' ? 's' : 'm'}
color={euiTheme.euiColorLightestShade}
iconColor="subdued"
iconType={iconType}
/>
);
const event = `${isIsolated ? 'isolated' : 'unisolated'} host`;
const hasComment = !!endpointAction.data.comment;
return (
<EuiComment
type={commentType}
username={endpointAction.user_id}
timestamp={FormattedDateAndTime({
date: new Date(endpointAction['@timestamp']),
showRelativeTime: true,
})}
event={event}
timelineIcon={timelineIcon}
data-test-subj="timelineEntry"
>
{hasComment ? (
<EuiText size="s">
<p>{endpointAction.data.comment}</p>
</EuiText>
) : undefined}
</EuiComment>
);
}
);
LogEntry.displayName = 'LogEntry';

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback } from 'react';
import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui';
import { LogEntry } from './components/log_entry';
import * as i18 from '../translations';
import { SearchBar } from '../../../../components/search_bar';
import { Immutable, EndpointAction } from '../../../../../../common/endpoint/types';
import { AsyncResourceState } from '../../../../state';
export const EndpointActivityLog = memo(
({ endpointActions }: { endpointActions: AsyncResourceState<Immutable<EndpointAction[]>> }) => {
// TODO
const onSearch = useCallback(() => {}, []);
return (
<>
<EuiSpacer size="l" />
{endpointActions.type !== 'LoadedResourceState' || !endpointActions.data.length ? (
<EuiEmptyPrompt
iconType="editorUnorderedList"
titleSize="s"
title={<h2>{'No logged actions'}</h2>}
body={<p>{'No actions have been logged for this endpoint.'}</p>}
/>
) : (
<>
<SearchBar onSearch={onSearch} placeholder={i18.SEARCH_ACTIVITY_LOG} />
<EuiSpacer size="l" />
{endpointActions.data.map((endpointAction) => (
<LogEntry key={endpointAction.action_id} endpointAction={endpointAction} />
))}
</>
)}
</>
);
}
);
EndpointActivityLog.displayName = 'EndpointActivityLog';

View file

@ -258,6 +258,7 @@ export const EndpointDetails = memo(
return (
<>
<EuiSpacer size="l" />
<EuiDescriptionList
type="column"
listItems={detailsResultsUpper}

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ComponentType } from 'react';
import moment from 'moment';
import { EndpointAction, Immutable } from '../../../../../../common/endpoint/types';
import { EndpointDetailsFlyoutTabs } from './components/endpoint_details_tabs';
import { EndpointActivityLog } from './endpoint_activity_log';
import { EndpointDetailsFlyout } from '.';
import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common';
import { AsyncResourceState } from '../../../../state';
export const dummyEndpointActivityLog = (
selectedEndpoint: string = ''
): AsyncResourceState<Immutable<EndpointAction[]>> => ({
type: 'LoadedResourceState',
data: [
{
action_id: '1',
'@timestamp': moment().subtract(1, 'hours').fromNow().toString(),
expiration: moment().add(3, 'day').fromNow().toString(),
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: [`${selectedEndpoint}`],
user_id: 'sys',
data: {
command: 'isolate',
},
},
{
action_id: '2',
'@timestamp': moment().subtract(2, 'hours').fromNow().toString(),
expiration: moment().add(1, 'day').fromNow().toString(),
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: [`${selectedEndpoint}`],
user_id: 'ash',
data: {
command: 'isolate',
comment: 'Sem et tortor consequat id porta nibh venenatis cras sed.',
},
},
{
action_id: '3',
'@timestamp': moment().subtract(4, 'hours').fromNow().toString(),
expiration: moment().add(1, 'day').fromNow().toString(),
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: [`${selectedEndpoint}`],
user_id: 'someone',
data: {
command: 'unisolate',
comment: 'Turpis egestas pretium aenean pharetra.',
},
},
{
action_id: '4',
'@timestamp': moment().subtract(1, 'day').fromNow().toString(),
expiration: moment().add(3, 'day').fromNow().toString(),
type: 'INPUT_ACTION',
input_type: 'endpoint',
agents: [`${selectedEndpoint}`],
user_id: 'ash',
data: {
command: 'isolate',
comment:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
},
],
});
export default {
title: 'Endpoints/Endpoint Details',
component: EndpointDetailsFlyout,
decorators: [
(Story: ComponentType) => (
<EuiThemeProvider>
<Story />
</EuiThemeProvider>
),
],
};
export const Tabs = () => (
<EndpointDetailsFlyoutTabs
show="details"
tabs={[
{
id: 'overview',
name: 'Overview',
content: <>{'Endpoint Details'}</>,
},
{
id: 'activity_log',
name: 'Activity Log',
content: ActivityLog(),
},
]}
/>
);
export const ActivityLog = () => (
<EndpointActivityLog endpointActions={dummyEndpointActivityLog()} />
);

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import React, { useCallback, useEffect, memo } from 'react';
import React, { useCallback, useEffect, useMemo, memo } from 'react';
import styled from 'styled-components';
import {
EuiFlyout,
EuiFlyoutBody,
@ -16,6 +17,8 @@ import {
EuiSpacer,
EuiEmptyPrompt,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
@ -26,8 +29,11 @@ import {
uiQueryParams,
detailsData,
detailsError,
showView,
detailsLoading,
getActivityLogData,
getActivityLogError,
getActivityLogRequestLoading,
showView,
policyResponseConfigurations,
policyResponseActions,
policyResponseFailedOrWarningActionCount,
@ -39,14 +45,36 @@ import {
policyResponseAppliedRevision,
} from '../../store/selectors';
import { EndpointDetails } from './endpoint_details';
import { EndpointActivityLog } from './endpoint_activity_log';
import { PolicyResponse } from './policy_response';
import * as i18 from '../translations';
import { HostMetadata } from '../../../../../../common/endpoint/types';
import {
EndpointDetailsFlyoutTabs,
EndpointDetailsTabsTypes,
} from './components/endpoint_details_tabs';
import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date';
import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel';
import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader';
import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding';
import { getEndpointListPath } from '../../../../common/routing';
const DetailsFlyoutBody = styled(EuiFlyoutBody)`
overflow-y: hidden;
flex: 1;
.euiFlyoutBody__overflow {
overflow: hidden;
mask-image: none;
}
.euiFlyoutBody__overflowContent {
height: 100%;
display: flex;
}
`;
export const EndpointDetailsFlyout = memo(() => {
const history = useHistory();
const toasts = useToasts();
@ -55,13 +83,51 @@ export const EndpointDetailsFlyout = memo(() => {
selected_endpoint: selectedEndpoint,
...queryParamsWithoutSelectedEndpoint
} = queryParams;
const details = useEndpointSelector(detailsData);
const activityLog = useEndpointSelector(getActivityLogData);
const activityLoading = useEndpointSelector(getActivityLogRequestLoading);
const activityError = useEndpointSelector(getActivityLogError);
const hostDetails = useEndpointSelector(detailsData);
const hostDetailsLoading = useEndpointSelector(detailsLoading);
const hostDetailsError = useEndpointSelector(detailsError);
const policyInfo = useEndpointSelector(policyVersionInfo);
const hostStatus = useEndpointSelector(hostStatusInfo);
const loading = useEndpointSelector(detailsLoading);
const error = useEndpointSelector(detailsError);
const show = useEndpointSelector(showView);
const ContentLoadingMarkup = useMemo(
() => (
<>
<EuiLoadingContent lines={3} />
<EuiSpacer size="l" />
<EuiLoadingContent lines={3} />
</>
),
[]
);
const tabs = [
{
id: EndpointDetailsTabsTypes.overview,
name: i18.OVERVIEW,
content:
hostDetails === undefined ? (
ContentLoadingMarkup
) : (
<EndpointDetails details={hostDetails} policyInfo={policyInfo} hostStatus={hostStatus} />
),
},
{
id: EndpointDetailsTabsTypes.activityLog,
name: i18.ACTIVITY_LOG,
content: activityLoading ? (
ContentLoadingMarkup
) : (
<EndpointActivityLog endpointActions={activityLog} />
),
},
];
const handleFlyoutClose = useCallback(() => {
const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint;
history.push(
@ -73,7 +139,7 @@ export const EndpointDetailsFlyout = memo(() => {
}, [history, queryParamsWithoutSelectedEndpoint]);
useEffect(() => {
if (error !== undefined) {
if (hostDetailsError !== undefined) {
toasts.addDanger({
title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', {
defaultMessage: 'Could not find host',
@ -83,7 +149,17 @@ export const EndpointDetailsFlyout = memo(() => {
}),
});
}
}, [error, toasts]);
if (activityError !== undefined) {
toasts.addDanger({
title: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorTitle', {
defaultMessage: 'Could not find activity log for host',
}),
text: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorBody', {
defaultMessage: 'Please exit the flyout and select another host with actions.',
}),
});
}
}, [hostDetailsError, activityError, toasts]);
return (
<EuiFlyout
@ -91,38 +167,43 @@ export const EndpointDetailsFlyout = memo(() => {
style={{ zIndex: 4001 }}
data-test-subj="endpointDetailsFlyout"
size="m"
paddingSize="m"
>
<EuiFlyoutHeader hasBorder>
{loading ? (
{hostDetailsLoading || activityLoading ? (
<EuiLoadingContent lines={1} />
) : (
<EuiToolTip content={details?.host?.hostname} anchorClassName="eui-textTruncate">
<EuiToolTip content={hostDetails?.host?.hostname} anchorClassName="eui-textTruncate">
<EuiTitle size="s">
<h2
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
data-test-subj="endpointDetailsFlyoutTitle"
>
{details?.host?.hostname}
{hostDetails?.host?.hostname}
</h2>
</EuiTitle>
</EuiToolTip>
)}
</EuiFlyoutHeader>
{details === undefined ? (
{hostDetails === undefined ? (
<EuiFlyoutBody>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</EuiFlyoutBody>
) : (
<>
{show === 'details' && (
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EndpointDetails details={details} policyInfo={policyInfo} hostStatus={hostStatus} />
</EuiFlyoutBody>
{(show === 'details' || show === 'activity_log') && (
<DetailsFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EuiFlexGroup>
<EuiFlexItem>
<EndpointDetailsFlyoutTabs show={show} tabs={tabs} />
</EuiFlexItem>
</EuiFlexGroup>
</DetailsFlyoutBody>
)}
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />}
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={hostDetails} />}
{show === 'isolate' && <EndpointIsolateFlyoutPanel hostMeta={details} />}
{show === 'isolate' && <EndpointIsolateFlyoutPanel hostMeta={hostDetails} />}
</>
)}
</EuiFlyout>

View file

@ -0,0 +1,23 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const OVERVIEW = i18n.translate('xpack.securitySolution.endpointDetails.overview', {
defaultMessage: 'Overview',
});
export const ACTIVITY_LOG = i18n.translate('xpack.securitySolution.endpointDetails.activityLog', {
defaultMessage: 'Activity Log',
});
export const SEARCH_ACTIVITY_LOG = i18n.translate(
'xpack.securitySolution.endpointDetails.activityLog.search',
{
defaultMessage: 'Search activity log',
}
);

View file

@ -30,7 +30,7 @@ import {
GetOneTrustedAppResponse,
} from '../../../../../common/endpoint/types/trusted_apps';
import { resolvePathVariables } from './utils';
import { resolvePathVariables } from '../../../common/utils';
import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest';
export interface TrustedAppsService {

View file

@ -1,45 +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 { resolvePathVariables } from './utils';
describe('utils', () => {
describe('resolvePathVariables', () => {
it('should resolve defined variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe(
'/segment1/value1/segment2'
);
});
it('should not resolve undefined variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe(
'/segment1/{var1}/segment2'
);
});
it('should ignore unused variables', () => {
expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe(
'/segment1/{var1}/segment2'
);
});
it('should replace multiple variable occurences', () => {
expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe(
'/value1/segment1/value1'
);
});
it('should replace multiple variables', () => {
const path = resolvePathVariables('/{var1}/segment1/{var2}', {
var1: 'value1',
var2: 'value2',
});
expect(path).toBe('/value1/segment1/value2');
});
});
});

View file

@ -1,11 +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.
*/
export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) =>
Object.keys(variables).reduce((acc, paramName) => {
return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName]));
}, path);

View file

@ -31,7 +31,7 @@ import {
import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data';
import { isFailedResourceState, isLoadedResourceState } from '../state';
import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils';
import { resolvePathVariables } from '../service/utils';
import { resolvePathVariables } from '../../../common/utils';
import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';

View file

@ -19,14 +19,12 @@ import {
import { ImmutableCombineReducers } from '../../common/store';
import { Immutable } from '../../../common/endpoint/types';
import { ManagementState } from '../types';
import {
endpointListReducer,
initialEndpointListState,
} from '../pages/endpoint_hosts/store/reducer';
import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer';
import { initialTrustedAppsPageState } from '../pages/trusted_apps/store/builders';
import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer';
import { initialEventFiltersPageState } from '../pages/event_filters/store/builders';
import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer';
import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders';
const immutableCombineReducers: ImmutableCombineReducers = combineReducers;
@ -35,7 +33,7 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers;
*/
export const mockManagementState: Immutable<ManagementState> = {
[MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(),
[MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState,
[MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(),
[MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(),
[MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(),
};

View file

@ -0,0 +1,30 @@
/*
* 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 { ENDPOINT_ACTION_LOG_ROUTE } from '../../../../common/endpoint/constants';
import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions';
import { actionsLogRequestHandler } from './audit_log_handler';
import { SecuritySolutionPluginRouter } from '../../../types';
import { EndpointAppContext } from '../../types';
/**
* Registers the endpoint activity_log route
*/
export function registerActionAuditLogRoutes(
router: SecuritySolutionPluginRouter,
endpointContext: EndpointAppContext
) {
router.get(
{
path: ENDPOINT_ACTION_LOG_ROUTE,
validate: EndpointActionLogRequestSchema,
options: { authRequired: true, tags: ['access:securitySolution'] },
},
actionsLogRequestHandler(endpointContext)
);
}

View file

@ -0,0 +1,59 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import { RequestHandler } from 'kibana/server';
import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common';
import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions';
import { SecuritySolutionRequestHandlerContext } from '../../../types';
import { EndpointAppContext } from '../../types';
export const actionsLogRequestHandler = (
endpointContext: EndpointAppContext
): RequestHandler<
TypeOf<typeof EndpointActionLogRequestSchema.params>,
unknown,
unknown,
SecuritySolutionRequestHandlerContext
> => {
const logger = endpointContext.logFactory.get('audit_log');
return async (context, req, res) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
let result;
try {
result = await esClient.search({
index: AGENT_ACTIONS_INDEX,
body: {
query: {
match: {
agents: req.params.agent_id,
},
},
sort: [
{
'@timestamp': {
order: 'desc',
},
},
],
},
});
} catch (error) {
logger.error(error);
throw error;
}
if (result?.statusCode !== 200) {
logger.error(`Error fetching actions log for agent_id ${req.params.agent_id}`);
throw new Error(`Error fetching actions log for agent_id ${req.params.agent_id}`);
}
return res.ok({
body: result.body.hits.hits.map((e) => e._source),
});
};
};

View file

@ -6,3 +6,4 @@
*/
export * from './isolation';
export * from './audit_log';

View file

@ -75,7 +75,10 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency';
import { registerResolverRoutes } from './endpoint/routes/resolver';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import { registerHostIsolationRoutes } from './endpoint/routes/actions';
import {
registerHostIsolationRoutes,
registerActionAuditLogRoutes,
} from './endpoint/routes/actions';
import { EndpointArtifactClient, ManifestManager } from './endpoint/services';
import { EndpointAppContextService } from './endpoint/endpoint_app_context_services';
import { EndpointAppContext } from './endpoint/types';
@ -291,6 +294,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
registerPolicyRoutes(router, endpointContext);
registerTrustedAppsRoutes(router, endpointContext);
registerHostIsolationRoutes(router, endpointContext);
registerActionAuditLogRoutes(router, endpointContext);
const referenceRuleTypes = [
REFERENCE_RULE_ALERT_TYPE_ID,