[Security Solution][Endpoint] new dev script to listen for pending actions and send responses for them to ES (#134712)

* New script endpoint_action_responder that will (currently) continuously pull for pending actions and respond to them (by sending to ES both the Fleet action response and Endpoint action response)
* common services for creating security user and stack services
This commit is contained in:
Paul Tavares 2022-06-20 15:54:41 -04:00 committed by GitHub
parent b7c8ff5d7b
commit 021b6eab83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 508 additions and 0 deletions

View file

@ -0,0 +1,19 @@
/*
* 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 HORIZONTAL_LINE = '-'.repeat(80);
export const SUPPORTED_TOKENS = `The following tokens can be used in the Action request 'comment' to drive
the type of response that is sent:
Token Description
--------------------------- -------------------------------------------------------
RESPOND.STATE=SUCCESS Will ensure the Endpoint Action response is success
RESPOND.STATE=FAILURE Will ensure the Endpoint Action response is a failure
RESPOND.FLEET.STATE=SUCCESS Will ensure the Fleet Action response is success
RESPOND.FLEET.STATE=FAILURE Will ensure the Fleet Action response is a failure
`;

View file

@ -0,0 +1,67 @@
/*
* 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 { run, RunContext } from '@kbn/dev-cli-runner';
import { HORIZONTAL_LINE, SUPPORTED_TOKENS } from './constants';
import { runInAutoMode } from './run_in_auto_mode';
export const cli = () => {
run(
async (context: RunContext) => {
context.log.write(`
${HORIZONTAL_LINE}
Endpoint Action Responder
${HORIZONTAL_LINE}
`);
if (context.flags.mode === 'auto') {
return runInAutoMode(context);
}
context.log.warning(`exiting... Nothing to do. use '--help' to see list of options`);
context.log.write(`
${HORIZONTAL_LINE}
`);
},
{
description: `Respond to pending Endpoint actions.
${SUPPORTED_TOKENS}`,
flags: {
string: ['mode', 'kibana', 'elastic', 'username', 'password', 'delay'],
boolean: ['asSuperuser'],
default: {
mode: 'auto',
kibana: 'http://localhost:5601',
elastic: 'http://localhost:9200',
username: 'elastic',
password: 'changeme',
asSuperuser: false,
delay: '',
},
help: `
--mode The mode for running the tool. (Default: 'auto').
Value values are:
auto : tool will continue to run and checking for pending
actions periodically.
--username User name to be used for auth against elasticsearch and
kibana (Default: elastic).
**IMPORTANT:** This username's roles MUST have 'superuser']
and 'kibana_system' roles
--password User name Password (Default: changeme)
--asSuperuser If defined, then a Security super user will be created using the
the credentials defined via 'username' and 'password' options. This
new user will then be used to run this utility.
--delay The delay (in milliseconds) that should be applied before responding
to an action. (Default: 40000 (40s))
--kibana The url to Kibana (Default: http://localhost:5601)
--elastic The url to Elasticsearch (Default: http:localholst:9200)
`,
},
}
);
};

View file

@ -0,0 +1,144 @@
/*
* 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 { RunContext } from '@kbn/dev-cli-runner';
import { set } from 'lodash';
import { SUPPORTED_TOKENS } from './constants';
import { ActionDetails } from '../../../common/endpoint/types';
import { createRuntimeServices, RuntimeServices } from '../common/stack_services';
import {
fetchEndpointActionList,
sendEndpointActionResponse,
sendFleetActionResponse,
sleep,
} from './utils';
const ACTION_RESPONSE_DELAY = 40_000;
export const runInAutoMode = async ({
log,
flags: { username, password, asSuperuser, kibana, elastic, delay: _delay },
}: RunContext) => {
const runtimeServices = await createRuntimeServices({
log,
password: password as string,
username: username as string,
asSuperuser: asSuperuser as boolean,
elasticsearchUrl: elastic as string,
kibanaUrl: kibana as string,
});
log.write(` ${SUPPORTED_TOKENS}`);
const delay = Number(_delay) || ACTION_RESPONSE_DELAY;
do {
await checkPendingActionsAndRespond(runtimeServices, { delay });
await sleep(5_000);
} while (true);
};
const checkPendingActionsAndRespond = async (
{ kbnClient, esClient, log }: RuntimeServices,
{ delay = ACTION_RESPONSE_DELAY }: { delay?: number } = {}
) => {
let hasMore = true;
let nextPage = 1;
try {
while (hasMore) {
const { data: actions } = await fetchEndpointActionList(kbnClient, {
page: nextPage++,
pageSize: 100,
});
if (actions.length === 0) {
hasMore = false;
}
for (const action of actions) {
if (action.isCompleted === false) {
if (Date.now() - new Date(action.startedAt).getTime() >= delay) {
log.info(
`[${new Date().toLocaleTimeString()}]: Responding to [${
action.command
}] action [id: ${action.id}] agent: [${action.agents.join(', ')}]`
);
const tokens = parseCommentTokens(getActionComment(action));
log.verbose('tokens found in action:', tokens);
const fleetResponse = await sendFleetActionResponse(esClient, action, {
// If an Endpoint state token was found, then force the Fleet response to `success`
// so that we can actually generate an endpoint response below.
state: tokens.state ? 'success' : tokens.fleet.state,
});
// If not a fleet response error, then also sent the Endpoint Response
if (!fleetResponse.error) {
await sendEndpointActionResponse(esClient, action, { state: tokens.state });
}
}
}
}
}
} catch (e) {
log.error(`${e.message}. Run with '--verbose' option to see more`);
log.verbose(e);
}
};
interface CommentTokens {
state: 'success' | 'failure' | undefined;
fleet: {
state: 'success' | 'failure' | undefined;
};
}
const parseCommentTokens = (comment: string): CommentTokens => {
const response: CommentTokens = {
state: undefined,
fleet: {
state: undefined,
},
};
if (comment) {
const findTokensRegExp = /(respond\.\S*=\S*)/gi;
let matches;
while ((matches = findTokensRegExp.exec(comment)) !== null) {
const [key, value] = matches[0]
.toLowerCase()
.split('=')
.map((s) => s.trim());
set(response, key.split('.').slice(1), value);
}
}
return response;
};
const getActionComment = (action: ActionDetails): string => {
const actionRequest = action.logEntries.find(
(entry) => entry.type === 'fleetAction' || entry.type === 'action'
);
if (actionRequest) {
if (actionRequest.type === 'fleetAction') {
return actionRequest.item.data.data.comment ?? '';
}
if (actionRequest.type === 'action') {
return actionRequest.item.data.EndpointActions.data.comment ?? '';
}
}
return '';
};

View file

@ -0,0 +1,116 @@
/*
* 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 { KbnClient } from '@kbn/test';
import { Client } from '@elastic/elasticsearch';
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator';
import {
ENDPOINT_ACTION_RESPONSES_INDEX,
ENDPOINTS_ACTION_LIST_ROUTE,
} from '../../../common/endpoint/constants';
import {
ActionDetails,
ActionListApiResponse,
EndpointActionData,
EndpointActionResponse,
LogsEndpointActionResponse,
} from '../../../common/endpoint/types';
import { EndpointActionListRequestQuery } from '../../../common/endpoint/schema/actions';
import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator';
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
export const fleetActionGenerator = new FleetActionGenerator();
export const endpointActionGenerator = new EndpointActionGenerator();
export const sleep = (ms: number = 1000) => new Promise((r) => setTimeout(r, ms));
export const fetchEndpointActionList = async (
kbn: KbnClient,
options: EndpointActionListRequestQuery = {}
): Promise<ActionListApiResponse> => {
return (
await kbn.request<ActionListApiResponse>({
method: 'GET',
path: ENDPOINTS_ACTION_LIST_ROUTE,
query: options,
})
).data;
};
export const sendFleetActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<EndpointActionResponse> => {
const fleetResponse = fleetActionGenerator.generateResponse({
action_id: action.id,
agent_id: action.agents[0],
action_response: {
endpoint: {
ack: true,
},
},
});
// 20% of the time we generate an error
if (state === 'failure' || (!state && fleetActionGenerator.randomFloat() < 0.2)) {
fleetResponse.action_response = {};
fleetResponse.error = 'Agent failed to deliver message to endpoint due to unknown error';
} else {
// show it as success (generator currently always generates a `error`, so delete it)
delete fleetResponse.error;
}
await esClient.index(
{
index: AGENT_ACTIONS_RESULTS_INDEX,
body: fleetResponse,
refresh: 'wait_for',
},
ES_INDEX_OPTIONS
);
return fleetResponse;
};
export const sendEndpointActionResponse = async (
esClient: Client,
action: ActionDetails,
{ state }: { state?: 'success' | 'failure' } = {}
): Promise<LogsEndpointActionResponse> => {
// FIXME:PT Generate command specific responses
const endpointResponse = endpointActionGenerator.generateResponse({
agent: { id: action.agents[0] },
EndpointActions: {
action_id: action.id,
data: {
command: action.command as EndpointActionData['command'],
comment: '',
},
started_at: action.startedAt,
},
});
// 20% of the time we generate an error
if (state === 'failure' || (state !== 'success' && endpointActionGenerator.randomFloat() < 0.2)) {
endpointResponse.error = {
message: 'Endpoint encountered an error and was unable to apply action to host',
};
}
await esClient.index({
index: ENDPOINT_ACTION_RESPONSES_INDEX,
body: endpointResponse,
refresh: 'wait_for',
});
return endpointResponse;
};

View file

@ -0,0 +1,35 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import { userInfo } from 'os';
export const createSecuritySuperuser = async (
esClient: Client,
username: string = userInfo().username,
password: string = 'changeme'
): Promise<{ username: string; password: string; created: boolean }> => {
if (!username || !password) {
throw new Error(`username and password require values.`);
}
const addedUser = await esClient.transport.request<Promise<{ created: boolean }>>({
method: 'POST',
path: `_security/user/${username}`,
body: {
password,
roles: ['superuser', 'kibana_system'],
full_name: username,
},
});
return {
created: addedUser.created,
username,
password,
};
};

View file

@ -0,0 +1,118 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import { ToolingLog } from '@kbn/tooling-log';
import { KbnClient } from '@kbn/test';
import { createSecuritySuperuser } from './security_user_services';
export interface RuntimeServices {
kbnClient: KbnClient;
esClient: Client;
log: ToolingLog;
user: Readonly<{
username: string;
password: string;
}>;
}
interface CreateRuntimeServicesOptions {
kibanaUrl: string;
elasticsearchUrl: string;
username: string;
password: string;
log?: ToolingLog;
asSuperuser?: boolean;
}
export const createRuntimeServices = async ({
kibanaUrl,
elasticsearchUrl,
username: _username,
password: _password,
log = new ToolingLog(),
asSuperuser = false,
}: CreateRuntimeServicesOptions): Promise<RuntimeServices> => {
let username = _username;
let password = _password;
if (asSuperuser) {
const superuserResponse = await createSecuritySuperuser(
createEsClient({
url: elasticsearchUrl,
username,
password,
log,
})
);
({ username, password } = superuserResponse);
if (superuserResponse.created) {
log.info(`Kibana user [${username}] was crated with password [${password}]`);
}
}
return {
kbnClient: createKbnClient({ log, url: kibanaUrl, username, password }),
esClient: createEsClient({ log, url: elasticsearchUrl, username, password }),
log,
user: {
username,
password,
},
};
};
const buildUrlWithCredentials = (url: string, username: string, password: string): string => {
const newUrl = new URL(url);
newUrl.username = username;
newUrl.password = password;
return newUrl.href;
};
export const createEsClient = ({
url,
username,
password,
log,
}: {
url: string;
username: string;
password: string;
log?: ToolingLog;
}): Client => {
const esUrl = buildUrlWithCredentials(url, username, password);
if (log) {
log.verbose(`Creating Elasticsearch client with URL: ${esUrl}`);
}
return new Client({ node: esUrl });
};
export const createKbnClient = ({
url,
username,
password,
log = new ToolingLog(),
}: {
url: string;
username: string;
password: string;
log?: ToolingLog;
}): KbnClient => {
const kbnUrl = buildUrlWithCredentials(url, username, password);
if (log) {
log.verbose(`Creating Kibana client with URL: ${kbnUrl}`);
}
return new KbnClient({ log, url: kbnUrl });
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
require('../../../../../src/setup_node_env');
require('./action_responder').cli();