mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Security Solution][Endpoint] Fix authz on File Info/Download APIs for execute
response action (#188698)
## Summary - Fixes the API route for response actions file information and file download to ensure that user only needs Authz to the Execute action. - Centralizes the logic to determine the platform for a given host which was (under certain data conditions) causing the platform icon to not be shown in the response console.
This commit is contained in:
parent
240d988ce3
commit
1ac9c8e2dc
13 changed files with 238 additions and 22 deletions
|
@ -9,6 +9,7 @@ import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
|||
import { useMemo } from 'react';
|
||||
import { find, some } from 'lodash/fp';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getHostPlatform } from '../../lib/endpoint/utils/get_host_platform';
|
||||
import { getAlertDetailsFieldValue } from '../../lib/endpoint/utils/get_event_details_field_values';
|
||||
import { isAgentTypeAndActionSupported } from '../../lib/endpoint';
|
||||
import type {
|
||||
|
@ -176,16 +177,8 @@ export const useAlertResponseActionsSupport = (
|
|||
}, [eventData]);
|
||||
|
||||
const platform = useMemo(() => {
|
||||
// TODO:TC I couldn't find host.os.family in the example data, thus using host.os.type and host.os.platform which are present one at a time in different type of events
|
||||
if (agentType === 'crowdstrike') {
|
||||
return (
|
||||
getAlertDetailsFieldValue({ category: 'host', field: 'host.os.type' }, eventData) ||
|
||||
getAlertDetailsFieldValue({ category: 'host', field: 'host.os.platform' }, eventData)
|
||||
);
|
||||
}
|
||||
|
||||
return getAlertDetailsFieldValue({ category: 'host', field: 'host.os.type' }, eventData);
|
||||
}, [agentType, eventData]);
|
||||
return getHostPlatform(eventData ?? []);
|
||||
}, [eventData]);
|
||||
|
||||
const unsupportedReason = useMemo(() => {
|
||||
if (!doesHostSupportResponseActions) {
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 { set } from 'lodash';
|
||||
import { getHostPlatform } from './get_host_platform';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
|
||||
describe('getHostPlatform() util', () => {
|
||||
const buildEcsData = (data: Record<string, string>) => {
|
||||
const ecsData = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
set(ecsData, `host.os.${key}`, value);
|
||||
}
|
||||
|
||||
return ecsData;
|
||||
};
|
||||
|
||||
const buildEventDetails = (data: Record<string, string>) => {
|
||||
const eventDetails: TimelineEventsDetailsItem[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
eventDetails.push({
|
||||
category: 'host',
|
||||
field: `host.os.${key}`,
|
||||
values: [value],
|
||||
originalValue: value,
|
||||
isObjectArray: false,
|
||||
});
|
||||
}
|
||||
|
||||
return eventDetails;
|
||||
};
|
||||
|
||||
it.each`
|
||||
title | setupData | expectedResult
|
||||
${'ECS data with host.os.platform info'} | ${buildEcsData({ platform: 'windows' })} | ${'windows'}
|
||||
${'ECS data with host.os.type info'} | ${buildEcsData({ type: 'Linux' })} | ${'linux'}
|
||||
${'ECS data with host.os.name info'} | ${buildEcsData({ name: 'MACOS' })} | ${'macos'}
|
||||
${'ECS data with all os info'} | ${buildEcsData({ platform: 'macos', type: 'windows', name: 'linux' })} | ${'macos'}
|
||||
${'Event Details data with host.os.platform info'} | ${buildEventDetails({ platform: 'windows' })} | ${'windows'}
|
||||
${'Event Details data with host.os.type info'} | ${buildEventDetails({ type: 'Linux' })} | ${'linux'}
|
||||
${'Event Details data with host.os.name info'} | ${buildEventDetails({ name: 'MACOS' })} | ${'macos'}
|
||||
${'Event Details data with all os info'} | ${buildEventDetails({ platform: 'macos', type: 'windows', name: 'linux' })} | ${'macos'}
|
||||
`(`should handle $title`, ({ setupData, expectedResult }) => {
|
||||
expect(getHostPlatform(setupData)).toEqual(expectedResult);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Ecs } from '@elastic/ecs';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import type { MaybeImmutable } from '../../../../../common/endpoint/types';
|
||||
import { getAlertDetailsFieldValue } from './get_event_details_field_values';
|
||||
import type { Platform } from '../../../../management/components/endpoint_responder/components/header_info/platforms';
|
||||
|
||||
type EcsHostData = MaybeImmutable<Pick<Ecs, 'host'>>;
|
||||
|
||||
const isTimelineEventDetailsItems = (
|
||||
data: EcsHostData | TimelineEventsDetailsItem[]
|
||||
): data is TimelineEventsDetailsItem[] => {
|
||||
return Array.isArray(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a host's platform type from either ECS data or Event Details list of items
|
||||
* @param data
|
||||
*/
|
||||
export const getHostPlatform = (data: EcsHostData | TimelineEventsDetailsItem[]): Platform => {
|
||||
let platform = '';
|
||||
|
||||
if (isTimelineEventDetailsItems(data)) {
|
||||
platform = (getAlertDetailsFieldValue({ category: 'host', field: 'host.os.platform' }, data) ||
|
||||
getAlertDetailsFieldValue({ category: 'host', field: 'host.os.type' }, data) ||
|
||||
getAlertDetailsFieldValue({ category: 'host', field: 'host.os.name' }, data)) as Platform;
|
||||
} else {
|
||||
platform =
|
||||
((data.host?.os?.platform || data.host?.os?.type || data.host?.os?.name) as Platform) || '';
|
||||
}
|
||||
|
||||
return platform.toLowerCase() as Platform;
|
||||
};
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiSkeletonText } from '@elastic/eui';
|
||||
import { getHostPlatform } from '../../../../../../common/lib/endpoint/utils/get_host_platform';
|
||||
import { AgentStatus } from '../../../../../../common/components/endpoint/agents/agent_status';
|
||||
import { HeaderAgentInfo } from '../header_agent_info';
|
||||
import { useGetEndpointDetails } from '../../../../../hooks';
|
||||
|
@ -31,7 +32,7 @@ export const HeaderEndpointInfo = memo<HeaderEndpointInfoProps>(({ endpointId })
|
|||
|
||||
return (
|
||||
<HeaderAgentInfo
|
||||
platform={endpointDetails.metadata.host.os.name.toLowerCase() as Platform}
|
||||
platform={getHostPlatform(endpointDetails.metadata) as Platform}
|
||||
hostName={endpointDetails.metadata.host.name}
|
||||
lastCheckin={endpointDetails.last_checkin}
|
||||
agentType="endpoint"
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { pagePathGetters } from '@kbn/fleet-plugin/public';
|
||||
import type { Platform } from '../../../../components/endpoint_responder/components/header_info/platforms';
|
||||
import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants';
|
||||
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
|
||||
import { useWithShowResponder } from '../../../../hooks';
|
||||
|
@ -20,6 +19,7 @@ import { agentPolicies, uiQueryParams } from '../../store/selectors';
|
|||
import { useAppUrl } from '../../../../../common/lib/kibana/hooks';
|
||||
import type { ContextMenuItemNavByRouterProps } from '../../../../components/context_menu_with_router_support/context_menu_item_nav_by_router';
|
||||
import { isEndpointHostIsolated } from '../../../../../common/utils/validators';
|
||||
import { getHostPlatform } from '../../../../../common/lib/endpoint/utils/get_host_platform';
|
||||
|
||||
interface Options {
|
||||
isEndpointList: boolean;
|
||||
|
@ -131,7 +131,7 @@ export const useEndpointActionItems = (
|
|||
capabilities:
|
||||
(endpointMetadata.Endpoint.capabilities as EndpointCapabilities[]) ?? [],
|
||||
hostName: endpointMetadata.host.name,
|
||||
platform: endpointMetadata.host.os.name.toLowerCase() as Platform,
|
||||
platform: getHostPlatform(endpointMetadata),
|
||||
});
|
||||
},
|
||||
children: (
|
||||
|
|
|
@ -80,7 +80,12 @@ describe('Response Actions file download API', () => {
|
|||
it('should error if user has no authz to api', async () => {
|
||||
(
|
||||
(await httpHandlerContextMock.securitySolution).getEndpointAuthz as jest.Mock
|
||||
).mockResolvedValue(getEndpointAuthzInitialStateMock({ canWriteFileOperations: false }));
|
||||
).mockResolvedValue(
|
||||
getEndpointAuthzInitialStateMock({
|
||||
canWriteFileOperations: false,
|
||||
canWriteExecuteOperations: false,
|
||||
})
|
||||
);
|
||||
|
||||
await apiTestSetup
|
||||
.getRegisteredVersionedRoute('get', ACTION_AGENT_FILE_DOWNLOAD_ROUTE, '2023-10-31')
|
||||
|
|
|
@ -47,7 +47,7 @@ export const registerActionFileDownloadRoutes = (
|
|||
},
|
||||
},
|
||||
withEndpointAuthz(
|
||||
{ all: ['canWriteFileOperations'] },
|
||||
{ any: ['canWriteFileOperations', 'canWriteExecuteOperations'] },
|
||||
logger,
|
||||
getActionFileDownloadRouteHandler(endpointContext)
|
||||
)
|
||||
|
|
|
@ -69,7 +69,12 @@ describe('Response Action file info API', () => {
|
|||
it('should error if user has no authz to api', async () => {
|
||||
(
|
||||
(await httpHandlerContextMock.securitySolution).getEndpointAuthz as jest.Mock
|
||||
).mockResolvedValue(getEndpointAuthzInitialStateMock({ canWriteFileOperations: false }));
|
||||
).mockResolvedValue(
|
||||
getEndpointAuthzInitialStateMock({
|
||||
canWriteFileOperations: false,
|
||||
canWriteExecuteOperations: false,
|
||||
})
|
||||
);
|
||||
|
||||
await apiTestSetup
|
||||
.getRegisteredVersionedRoute('get', ACTION_AGENT_FILE_INFO_ROUTE, '2023-10-31')
|
||||
|
|
|
@ -83,7 +83,7 @@ export const registerActionFileInfoRoute = (
|
|||
},
|
||||
},
|
||||
withEndpointAuthz(
|
||||
{ all: ['canWriteFileOperations'] },
|
||||
{ any: ['canWriteFileOperations', 'canWriteExecuteOperations'] },
|
||||
endpointContext.logFactory.get('actionFileInfo'),
|
||||
getActionFileInfoRouteHandler(endpointContext)
|
||||
)
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { RequestHandler, Logger } from '@kbn/core/server';
|
||||
import { stringify } from '../utils/stringify';
|
||||
import type { EndpointAuthzKeyList } from '../../../common/endpoint/types/authz';
|
||||
import type { SecuritySolutionRequestHandlerContext } from '../../types';
|
||||
import { EndpointAuthorizationError } from '../errors';
|
||||
|
@ -39,6 +40,21 @@ export const withEndpointAuthz = <T>(
|
|||
const validateAll = needAll.length > 0;
|
||||
const validateAny = needAny.length > 0;
|
||||
const enforceAuthz = validateAll || validateAny;
|
||||
const logAuthzFailure = (
|
||||
user: string,
|
||||
authzValidationResults: Record<string, boolean>,
|
||||
needed: string[]
|
||||
) => {
|
||||
logger.debug(
|
||||
`Unauthorized: user ${user} ${
|
||||
needed === needAll ? 'needs ALL' : 'needs at least one'
|
||||
} of the following privileges:\n${stringify(needed)}\nbut is missing: ${stringify(
|
||||
Object.entries(authzValidationResults)
|
||||
.filter(([_, value]) => !value)
|
||||
.map(([key]) => key)
|
||||
)}`
|
||||
);
|
||||
};
|
||||
|
||||
if (!enforceAuthz) {
|
||||
logger.warn(`Authorization disabled for API route: ${new Error('').stack ?? '?'}`);
|
||||
|
@ -51,18 +67,37 @@ export const withEndpointAuthz = <T>(
|
|||
SecuritySolutionRequestHandlerContext
|
||||
> = async (context, request, response) => {
|
||||
if (enforceAuthz) {
|
||||
const coreServices = await context.core;
|
||||
const endpointAuthz = await (await context.securitySolution).getEndpointAuthz();
|
||||
const permissionChecker = (permission: EndpointAuthzKeyList[0]) => endpointAuthz[permission];
|
||||
let authzValidationResults: Record<string, boolean> = {};
|
||||
const permissionChecker = (permission: EndpointAuthzKeyList[0]) => {
|
||||
authzValidationResults[permission] = endpointAuthz[permission];
|
||||
return endpointAuthz[permission];
|
||||
};
|
||||
|
||||
// has `all`?
|
||||
if (validateAll && !needAll.every(permissionChecker)) {
|
||||
logAuthzFailure(
|
||||
coreServices.security.authc.getCurrentUser()?.username ?? '',
|
||||
authzValidationResults,
|
||||
needAll
|
||||
);
|
||||
|
||||
return response.forbidden({
|
||||
body: new EndpointAuthorizationError({ need_all: [...needAll] }),
|
||||
});
|
||||
}
|
||||
|
||||
authzValidationResults = {};
|
||||
|
||||
// has `any`?
|
||||
if (validateAny && !needAny.some(permissionChecker)) {
|
||||
logAuthzFailure(
|
||||
coreServices.security.authc.getCurrentUser()?.username ?? '',
|
||||
authzValidationResults,
|
||||
needAny
|
||||
);
|
||||
|
||||
return response.forbidden({
|
||||
body: new EndpointAuthorizationError({ need_any: [...needAny] }),
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Role } from '@kbn/security-plugin/common';
|
||||
import {
|
||||
EndpointSecurityRoleNames,
|
||||
ENDPOINT_SECURITY_ROLE_NAMES,
|
||||
|
@ -61,9 +62,25 @@ export function RolesUsersProvider({ getService }: FtrProviderContext) {
|
|||
await security.role.create(predefinedRole, roleConfig);
|
||||
}
|
||||
if (customRole) {
|
||||
await security.role.create(customRole.roleName, {
|
||||
permissions: { feature: { siem: [...customRole.extraPrivileges] } },
|
||||
});
|
||||
const role: Omit<Role, 'name'> = {
|
||||
description: '',
|
||||
elasticsearch: {
|
||||
cluster: [],
|
||||
indices: [],
|
||||
run_as: [],
|
||||
},
|
||||
kibana: [
|
||||
{
|
||||
spaces: ['*'],
|
||||
base: [],
|
||||
feature: {
|
||||
siem: customRole.extraPrivileges,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await security.role.create(customRole.roleName, role);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -6,14 +6,21 @@
|
|||
*/
|
||||
import { wrapErrorAndRejectPromise } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/utils';
|
||||
import expect from '@kbn/expect';
|
||||
import { EXECUTE_ROUTE } from '@kbn/security-solution-plugin/common/endpoint/constants';
|
||||
import {
|
||||
ACTION_AGENT_FILE_INFO_ROUTE,
|
||||
EXECUTE_ROUTE,
|
||||
} from '@kbn/security-solution-plugin/common/endpoint/constants';
|
||||
import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data';
|
||||
import { ActionDetails } from '@kbn/security-solution-plugin/common/endpoint/types';
|
||||
import { getFileDownloadId } from '@kbn/security-solution-plugin/common/endpoint/service/response_actions/get_file_download_id';
|
||||
import { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows';
|
||||
import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
const endpointTestResources = getService('endpointTestResources');
|
||||
const rolesUsersProvider = getService('rolesUsersProvider');
|
||||
|
||||
// @skipInServerlessMKI - this test uses internal index manipulation in before/after hooks
|
||||
describe('@ess @serverless @skipInServerlessMKI Endpoint `execute` response action', function () {
|
||||
let indexedData: IndexedHostsAndAlertsResponse;
|
||||
|
@ -150,5 +157,66 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(data.parameters.command).to.eql('ls -la');
|
||||
expect(data.parameters.timeout).to.eql(2000);
|
||||
});
|
||||
|
||||
// Test checks to ensure API works with a custom role
|
||||
describe('@skipInServerless @skipInServerlessMKI and with minimal authz', () => {
|
||||
const username = 'execute_limited';
|
||||
const password = 'changeme';
|
||||
let fileInfoApiRoutePath: string = '';
|
||||
|
||||
before(async () => {
|
||||
await rolesUsersProvider.createRole({
|
||||
customRole: {
|
||||
roleName: username,
|
||||
extraPrivileges: ['minimal_all', 'execute_operations_all'],
|
||||
},
|
||||
});
|
||||
await rolesUsersProvider.createUser({ name: username, password, roles: [username] });
|
||||
|
||||
const {
|
||||
body: { data },
|
||||
} = await supertestWithoutAuth
|
||||
.post(EXECUTE_ROUTE)
|
||||
.auth(username, password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('Elastic-Api-Version', '2023-10-31')
|
||||
.send({ endpoint_ids: [agentId], parameters: { command: 'ls -la' } })
|
||||
.expect(200);
|
||||
|
||||
const actionDetails = data as ActionDetails;
|
||||
|
||||
fileInfoApiRoutePath = ACTION_AGENT_FILE_INFO_ROUTE.replace('{action_id}', data.id).replace(
|
||||
'{file_id}',
|
||||
getFileDownloadId(actionDetails)
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rolesUsersProvider.deleteRoles([username]);
|
||||
await rolesUsersProvider.deleteUsers([username]);
|
||||
});
|
||||
|
||||
it('should have access to file info api', async () => {
|
||||
await supertestWithoutAuth
|
||||
.get(fileInfoApiRoutePath)
|
||||
.auth(username, password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('Elastic-Api-Version', '2023-10-31')
|
||||
// We expect 404 because the indexes with the file info don't exist.
|
||||
// The key here is that we do NOT get a 401 or 403
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should have access to file download api', async () => {
|
||||
await supertestWithoutAuth
|
||||
.get(`${fileInfoApiRoutePath}/download`)
|
||||
.auth(username, password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('Elastic-Api-Version', '2023-10-31')
|
||||
// We expect 404 because the indexes with the file info don't exist.
|
||||
// The key here is that we do NOT get a 401 or 403
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -46,5 +46,6 @@
|
|||
"@kbn/utility-types",
|
||||
"@kbn/timelines-plugin",
|
||||
"@kbn/dev-cli-runner",
|
||||
"@kbn/security-plugin",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue