Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EDR Workflows][WIP] Fetch custom scripts and use it as SelectorComponent for CloudFile #204965

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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 { EndpointAgentStatusRequestSchema } from './get_agent_status_route';

describe('Agent status api route schema', () => {
it('should optionally accept `agentType`', () => {
expect(() =>
EndpointAgentStatusRequestSchema.query.validate({
agentIds: '1',
})
).not.toThrow();
});

it('should error if unknown `agentType` is used', () => {
expect(() =>
EndpointAgentStatusRequestSchema.query.validate({
agentIds: '1',
agentType: 'foo',
})
).toThrow(/\[agentType]: types that failed validation/);
});

it.each([
['string with spaces only', { agentIds: ' ' }],
['empty string', { agentIds: '' }],
['array with empty strings', { agentIds: [' ', ''] }],
['agentIds not defined', {}],
['agentIds is empty array', { agentIds: [] }],
[
'more than 50 agentIds',
{ agentIds: Array.from({ length: 51 }, () => Math.random().toString(32)) },
],
])('should error if %s are used for `agentIds`', (_, validateOptions) => {
expect(() => EndpointAgentStatusRequestSchema.query.validate(validateOptions)).toThrow(
/\[agentIds]:/
);
});

it.each([
['single string value', 'one'],
['array of strings', ['one', 'two']],
])('should accept %s of `agentIds`', (_, agentIdsValue) => {
expect(() =>
EndpointAgentStatusRequestSchema.query.validate({
agentIds: agentIdsValue,
})
).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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 { schema, type TypeOf } from '@kbn/config-schema';
import { AgentTypeSchemaLiteral } from '..';

export const CustomScriptsRequestSchema = {
query: schema.object({
agentType: schema.maybe(
schema.oneOf(
// @ts-expect-error TS2769: No overload matches this call
AgentTypeSchemaLiteral,
{
defaultValue: 'endpoint',
}
)
),
}),
};

export type CustomScriptsRequestQueryParams = TypeOf<typeof CustomScriptsRequestSchema.query>;
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export const ACTION_STATE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/state`;
/** Endpoint Agent Routes */
export const AGENT_STATUS_ROUTE = `/internal${BASE_ENDPOINT_ROUTE}/agent_status`;

/** Custom Scripts Routes */
export const CUSTOM_SCRIPTS_ROUTE = `/internal${BASE_ENDPOINT_ROUTE}/custom_scripts`;

export const failedFleetActionErrorCode = '424';

export const ENDPOINT_DEFAULT_PAGE = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 React, { memo, useCallback, useMemo, useState } from 'react';
import {
EuiPopover,
EuiSuperSelect,
EuiText,
EuiFlexGroup,
EuiFlexItem,
htmlIdGenerator,
} from '@elastic/eui';
import type { EuiFilePickerProps } from '@elastic/eui/src/components/form/file_picker/file_picker';
import { i18n } from '@kbn/i18n';
import { useGetCustomScripts } from '../../hooks/custom_scripts/use_get_custom_scripts';
import type { CommandArgumentValueSelectorProps } from '../console/types';

const INITIAL_DISPLAY_LABEL = i18n.translate(
'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.initialDisplayLabel',
{ defaultMessage: 'Click to select script' }
);

const OPEN_FILE_PICKER_LABEL = i18n.translate(
'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.filePickerButtonLabel',
{ defaultMessage: 'Open scripts picker' }
);

const NO_FILE_SELECTED = i18n.translate(
'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.noFileSelected',
{ defaultMessage: 'No script selected' }
);

interface ArgumentFileSelectorState {
isPopoverOpen: boolean;
}

/**
* A Console Argument Selector component that enables the user to select a file from the local machine
*/
export const CustomScriptSelector = memo<
CommandArgumentValueSelectorProps<string, ArgumentFileSelectorState>
>(({ value, valueText, onChange, store: _store }) => {
const state = useMemo<ArgumentFileSelectorState>(() => {
return _store ?? { isPopoverOpen: true };
}, [_store]);

const setIsPopoverOpen = useCallback(
(newValue: boolean) => {
onChange({
value,
valueText,
store: {
...state,
isPopoverOpen: newValue,
},
});
},
[onChange, state, value, valueText]
);
console.log({ store: _store });
const { data = [] } = useGetCustomScripts('crowdstrike');

const scriptsOptions = useMemo(() => {
return data.map((script) => ({
value: script.name,
inputDisplay: script.name,
dropdownDisplay: (
<>
<strong>{script.name}</strong>
<EuiText size="s" color="subdued">
<p>{script.description}</p>
</EuiText>
</>
),
}));
}, [data]);

const filePickerUUID = useMemo(() => {
return htmlIdGenerator('console')();
}, []);
const handleOpenPopover = useCallback(() => {
setIsPopoverOpen(true);
}, [setIsPopoverOpen]);

const handleClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, [setIsPopoverOpen]);

const [selectedScript, setSelectedScript] = useState<string>('');
const handleScriptSelection = useCallback<NonNullable<EuiFilePickerProps['onChange']>>(
(selectedScript) => {
console.log({ selectedScript });

// Get only the first file selected
setSelectedScript(selectedScript);
onChange({
value: selectedScript ?? undefined,
valueText: selectedScript || '',
store: {
...state,
isPopoverOpen: false,
},
});
// TODO fix focus back to the input and not Back > button
},
[onChange, state]
);

return (
<div>
<EuiPopover
isOpen={state.isPopoverOpen}
closePopover={handleClosePopover}
anchorPosition="upCenter"
initialFocus={`[id="${filePickerUUID}"]`}
button={
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
<EuiFlexItem grow={false} className="eui-textTruncate" onClick={handleOpenPopover}>
<div className="eui-textTruncate" title={valueText || NO_FILE_SELECTED}>
{valueText || INITIAL_DISPLAY_LABEL}
</div>
</EuiFlexItem>
</EuiFlexGroup>
}
>
{state.isPopoverOpen && (
<EuiSuperSelect
options={scriptsOptions}
valueOfSelected={selectedScript}
placeholder="Select an option"
itemLayoutAlign="top"
hasDividers
onChange={handleScriptSelection}
/>
)}
</EuiPopover>
</div>
);
});
CustomScriptSelector.displayName = 'ArgumentFileSelector';
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { i18n } from '@kbn/i18n';
import { CustomScriptSelector } from '../../console_argument_selectors/custom_script_selector';
import { RunScriptActionResult } from '../command_render_components/run_script_action';
import type { CommandArgDefinition } from '../../console/types';
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';
Expand Down Expand Up @@ -558,6 +559,7 @@ export const getEndpointConsoleCommands = ({
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.cloudFile.about,
mustHaveValue: 'non-empty-string',
exclusiveOr: true,
SelectorComponent: CustomScriptSelector,
},
CommandLine: {
required: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { CUSTOM_SCRIPTS_ROUTE } from '../../../../common/endpoint/constants';
import { useHttp } from '../../../common/lib/kibana';

interface ErrorType {
statusCode: number;
message: string;
meta: ActionTypeExecutorResult<SentinelOneGetAgentsResponse>;
}

/**
* Retrieve the status of a supported host's agent type
* @param agentType
* @param options
*/
export const useGetCustomScripts = (
agentType: ResponseActionAgentType,
options: any = {}
): UseQueryResult<any, IHttpFetchError<ErrorType>> => {
const http = useHttp();

return useQuery<any, IHttpFetchError<ErrorType>>({
queryKey: ['get-agent-status', agentType],
// refetchInterval: DEFAULT_POLL_INTERVAL,
...options,
queryFn: () => {
return http
.get<any>(CUSTOM_SCRIPTS_ROUTE, {
version: '1',
query: {
agentType,
},
})
.then((response) => response.data);
},
});
};
Loading