Skip to content

Commit

Permalink
[Security Solution][Endpoint] add upload console response action (#…
Browse files Browse the repository at this point in the history
…157208)

## Summary

- Add the `upload` response action to the endpoint console
  • Loading branch information
paul-tavares authored May 11, 2023
1 parent 497b374 commit f9f4c1a
Show file tree
Hide file tree
Showing 26 changed files with 1,085 additions and 160 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,32 @@ export class EndpointActionGenerator extends BaseDataGenerator {
}
}

if (command === 'upload' && !output) {
let uploadOutput = output as ActionResponseOutput<ResponseActionUploadOutputContent>;

if (overrides.error) {
uploadOutput = {
type: 'json',
content: {
code: 'ra_upload_some-error',
path: '',
disk_free_space: 0,
},
};
} else {
uploadOutput = {
type: 'json',
content: {
code: 'ra_upload_file-success',
path: '/disk1/file/saved/here',
disk_free_space: 4825566125475,
},
};
}

output = uploadOutput as typeof output;
}

return merge(
{
'@timestamp': timeStamp.toISOString(),
Expand Down Expand Up @@ -242,21 +268,30 @@ export class EndpointActionGenerator extends BaseDataGenerator {
}

if (command === 'upload') {
if (!details.parameters) {
(
details as ActionDetails<
ResponseActionUploadOutputContent,
ResponseActionUploadParameters
>
).parameters = {
file: {
file_id: 'file-x-y-z',
file_name: 'foo.txt',
size: 1234,
sha256: 'file-hash-sha-256',
const uploadActionDetails = details as ActionDetails<
ResponseActionUploadOutputContent,
ResponseActionUploadParameters
>;

uploadActionDetails.parameters = {
file: {
file_id: 'file-x-y-z',
file_name: 'foo.txt',
size: 1234,
sha256: 'file-hash-sha-256',
},
};

uploadActionDetails.outputs = {
'agent-a': {
type: 'json',
content: {
code: 'ra_upload_file-success',
path: '/path/to/uploaded/file',
disk_free_space: 1234567,
},
};
}
},
};
}

return merge(details, overrides as ActionDetails) as unknown as ActionDetails<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ describe('actions schemas', () => {
}).not.toThrow();
});

it('should allow `override` parameter', () => {
it('should allow `overwrite` parameter', () => {
expect(() => {
UploadActionRequestSchema.body.validate({
endpoint_ids: ['endpoint_id'],
Expand All @@ -676,10 +676,10 @@ describe('actions schemas', () => {
UploadActionRequestSchema.body.validate({
endpoint_ids: ['endpoint_id'],
parameters: {
override: true,
overwrite: true,
},
});
}).toThrow();
}).toThrow('[file]: expected value of type [Stream] but got [undefined]');
});

it('should error if `file` is not a Stream', () => {
Expand All @@ -691,7 +691,7 @@ describe('actions schemas', () => {
},
file: {},
});
}).toThrow();
}).toThrow('[file]: expected value of type [Stream] but got [Object]');
});
});
});
13 changes: 11 additions & 2 deletions x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,20 @@ export const UploadActionRequestSchema = {
...BaseActionRequestSchema,

parameters: schema.object({
overwrite: schema.maybe(schema.boolean()),
overwrite: schema.maybe(schema.boolean({ defaultValue: false })),
}),

file: schema.stream(),
}),
};

export type UploadActionRequestBody = TypeOf<typeof UploadActionRequestSchema.body>;
/** Type used by the server's API for `upload` action */
export type UploadActionApiRequestBody = TypeOf<typeof UploadActionRequestSchema.body>;

/**
* Type used on the UI side. The `file` definition is different on the UI side, thus the
* need for a separate type.
*/
export type UploadActionUIRequestBody = Omit<UploadActionApiRequestBody, 'file'> & {
file: File;
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks';
import { createLicenseServiceMock } from '../../../license/mocks';
import type { EndpointAuthzKeyList } from '../../types/authz';
import {
commandToRBACMap,
RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL,
CONSOLE_RESPONSE_ACTION_COMMANDS,
type ResponseConsoleRbacControls,
} from '../response_actions/constants';
Expand Down Expand Up @@ -129,7 +129,7 @@ describe('Endpoint Authz service', () => {
const responseConsolePrivileges = CONSOLE_RESPONSE_ACTION_COMMANDS.slice().reduce<
ResponseConsoleRbacControls[]
>((acc, e) => {
const item = commandToRBACMap[e];
const item = RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL[e];
if (!acc.includes(item)) {
acc.push(item);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EndpointAuthzKeyList } from '../../types/authz';

export const RESPONSE_ACTION_STATUS = ['failed', 'pending', 'successful'] as const;
export type ResponseActionStatus = typeof RESPONSE_ACTION_STATUS[number];

Expand Down Expand Up @@ -66,19 +68,21 @@ export type ResponseConsoleRbacControls =
| 'writeExecuteOperations';

/**
* maps the console command to the RBAC control that is required to access it via console
* maps the console command to the RBAC control (kibana feature control) that is required to access it via console
*/
export const commandToRBACMap: Record<ConsoleResponseActionCommands, ResponseConsoleRbacControls> =
Object.freeze({
isolate: 'writeHostIsolation',
release: 'writeHostIsolation',
'kill-process': 'writeProcessOperations',
'suspend-process': 'writeProcessOperations',
processes: 'writeProcessOperations',
'get-file': 'writeFileOperations',
execute: 'writeExecuteOperations',
upload: 'writeFileOperations',
});
export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL: Record<
ConsoleResponseActionCommands,
ResponseConsoleRbacControls
> = Object.freeze({
isolate: 'writeHostIsolation',
release: 'writeHostIsolation',
'kill-process': 'writeProcessOperations',
'suspend-process': 'writeProcessOperations',
processes: 'writeProcessOperations',
'get-file': 'writeFileOperations',
execute: 'writeExecuteOperations',
upload: 'writeFileOperations',
});

export const RESPONSE_ACTION_API_COMMANDS_TO_CONSOLE_COMMAND_MAP = Object.freeze<
Record<ResponseActionsApiCommandNames, ConsoleResponseActionCommands>
Expand All @@ -93,6 +97,35 @@ export const RESPONSE_ACTION_API_COMMANDS_TO_CONSOLE_COMMAND_MAP = Object.freeze
upload: 'upload',
});

export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_ENDPOINT_CAPABILITY = Object.freeze<
Record<ConsoleResponseActionCommands, EndpointCapabilities>
>({
isolate: 'isolation',
release: 'isolation',
execute: 'execute',
'get-file': 'get_file',
processes: 'running_processes',
'kill-process': 'kill_process',
'suspend-process': 'suspend_process',
upload: 'upload_file',
});

/**
* The list of console commands mapped to the required EndpointAuthz to access that command
*/
export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze<
Record<ConsoleResponseActionCommands, EndpointAuthzKeyList[number]>
>({
isolate: 'canIsolateHost',
release: 'canUnIsolateHost',
execute: 'canWriteExecuteOperations',
'get-file': 'canWriteFileOperations',
upload: 'canWriteFileOperations',
processes: 'canGetRunningProcesses',
'kill-process': 'canKillProcess',
'suspend-process': 'canSuspendProcess',
});

// 4 hrs in seconds
// 4 * 60 * 60
export const DEFAULT_EXECUTE_ACTION_TIMEOUT = 14400;
22 changes: 11 additions & 11 deletions x-pack/plugins/security_solution/common/endpoint/types/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
NoParametersRequestSchema,
ResponseActionBodySchema,
KillOrSuspendProcessRequestSchema,
UploadActionRequestBody,
UploadActionApiRequestBody,
} from '../schema/actions';
import type {
ResponseActionStatus,
Expand Down Expand Up @@ -316,6 +316,13 @@ export interface PendingActionsResponse {

export type PendingActionsRequestQuery = TypeOf<typeof ActionStatusRequestSchema.query>;

export interface ActionDetailsAgentState {
isCompleted: boolean;
wasSuccessful: boolean;
errors: undefined | string[];
completedAt: string | undefined;
}

export interface ActionDetails<
TOutputContent extends object = object,
TParameters extends EndpointActionDataParameterTypes = EndpointActionDataParameterTypes
Expand Down Expand Up @@ -360,15 +367,7 @@ export interface ActionDetails<
* A map by Agent ID holding information about the action for the specific agent.
* Helpful when action is sent to multiple agents
*/
agentState: Record<
string,
{
isCompleted: boolean;
wasSuccessful: boolean;
errors: undefined | string[];
completedAt: string | undefined;
}
>;
agentState: Record<string, ActionDetailsAgentState>;
/** action status */
status: ResponseActionStatus;
/** user that created the action */
Expand Down Expand Up @@ -477,7 +476,7 @@ export interface ActionFileInfoApiResponse {
* NOTE: Most of the parameters below are NOT accepted via the API. They are inserted into
* the action's parameters via the API route handler
*/
export type ResponseActionUploadParameters = UploadActionRequestBody['parameters'] & {
export type ResponseActionUploadParameters = UploadActionApiRequestBody['parameters'] & {
file: {
sha256: string;
size: number;
Expand All @@ -487,6 +486,7 @@ export type ResponseActionUploadParameters = UploadActionRequestBody['parameters
};

export interface ResponseActionUploadOutputContent {
code: string;
/** Full path to the file on the host machine where it was saved */
path: string;
/** The free space available (after saving the file) of the drive where the file was saved to, In Bytes */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type { AppContextTestRender } from '../../../../../../common/mock/endpoin
import { getConsoleTestSetup } from '../../../mocks';
import type { ConsoleTestSetup } from '../../../mocks';
import { waitFor } from '@testing-library/react';
import type { ConsoleProps } from '../../../types';
import type { ConsoleProps, CommandArgDefinition, CommandDefinition } from '../../../types';
import { executionTranslations } from './translations';

describe('When a Console command is entered by the user', () => {
let render: (props?: Partial<ConsoleProps>) => ReturnType<AppContextTestRender['render']>;
Expand Down Expand Up @@ -276,4 +277,65 @@ describe('When a Console command is entered by the user', () => {
expect(renderResult.getByTestId('exec-output')).toBeTruthy();
});
});

describe('Argument value validators', () => {
let command: CommandDefinition;

const setValidation = (validation: CommandArgDefinition['mustHaveValue']): void => {
command.args!.foo.mustHaveValue = validation;
};

beforeEach(() => {
command = commands.find(({ name }) => name === 'cmd3')!;
command.args!.foo.allowMultiples = false;
});

it('should validate argument with `mustHaveValue=non-empty-string', async () => {
setValidation('non-empty-string');
const { getByTestId } = render();
enterCommand('cmd3 --foo=""');

await waitFor(() => {
expect(getByTestId('test-badArgument-message')).toHaveTextContent(
executionTranslations.mustHaveValue('foo')
);
});
});

it('should validate argument with `mustHaveValue=truthy', async () => {
setValidation('truthy');
const { getByTestId } = render();
enterCommand('cmd3 --foo=""');

await waitFor(() => {
expect(getByTestId('test-badArgument-message')).toHaveTextContent(
executionTranslations.mustHaveValue('foo')
);
});
});

it('should validate argument with `mustHaveValue=number', async () => {
setValidation('number');
const { getByTestId } = render();
enterCommand('cmd3 --foo="hi"');

await waitFor(() => {
expect(getByTestId('test-badArgument-message')).toHaveTextContent(
executionTranslations.mustBeNumber('foo')
);
});
});

it('should validate argument with `mustHaveValue=number-greater-than-zero', async () => {
setValidation('number-greater-than-zero');
const { getByTestId } = render();
enterCommand('cmd3 --foo="0"');

await waitFor(() => {
expect(getByTestId('test-badArgument-message')).toHaveTextContent(
executionTranslations.mustBeGreaterThanZero('foo')
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ export const handleExecuteCommand: ConsoleStoreReducer<
}
break;

case 'truthy':
if (!argValue) {
dataValidationError = executionTranslations.mustHaveValue(argName);
}
break;

case 'number':
case 'number-greater-than-zero':
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const ConsoleWindow = styled.div`
&-historyViewport {
height: 100%;
overflow-x: hidden;
white-space: pre-wrap;
}
// min-width setting is needed for flex items to ensure that overflow works as expected
Expand Down
Loading

0 comments on commit f9f4c1a

Please sign in to comment.