mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
b7c8ff5d7b
commit
021b6eab83
7 changed files with 508 additions and 0 deletions
|
@ -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
|
||||
|
||||
`;
|
|
@ -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)
|
||||
`,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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 '';
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 });
|
||||
};
|
|
@ -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();
|
Loading…
Add table
Add a link
Reference in a new issue