[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:
Paul Tavares 2024-07-22 11:34:28 -04:00 committed by GitHub
parent 240d988ce3
commit 1ac9c8e2dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 238 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ export const registerActionFileDownloadRoutes = (
},
},
withEndpointAuthz(
{ all: ['canWriteFileOperations'] },
{ any: ['canWriteFileOperations', 'canWriteExecuteOperations'] },
logger,
getActionFileDownloadRouteHandler(endpointContext)
)

View file

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

View file

@ -83,7 +83,7 @@ export const registerActionFileInfoRoute = (
},
},
withEndpointAuthz(
{ all: ['canWriteFileOperations'] },
{ any: ['canWriteFileOperations', 'canWriteExecuteOperations'] },
endpointContext.logFactory.get('actionFileInfo'),
getActionFileInfoRouteHandler(endpointContext)
)

View file

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

View file

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

View file

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

View file

@ -46,5 +46,6 @@
"@kbn/utility-types",
"@kbn/timelines-plugin",
"@kbn/dev-cli-runner",
"@kbn/security-plugin",
]
}