[Defend 4 containers] ignoreVolumeMounts + ignoreVolumeFiles selector conditions added. (#151956)

## Summary

This adds two new selector conditions to our policy schema.
ignoreVolumeMounts and ignoreVolumeFiles. They are a easy route to avoid
users having to list all their k8s mounts in targetFilePath selector (as
an exclude selector). This PR also refactors the json schema for a
policy into it's own json file, so that it may be sync with the json
file in the cloud-defend repo.

Bonus commit: selectors now support "createFile, modifyFile and
deleteFile" operations. (FIM capabilities)

Context: 
https://github.com/elastic/security-team/issues/5718

https://github.com/elastic/integrations/tree/main/packages/cloud_defend#selectors


![image](https://user-images.githubusercontent.com/16198204/220793759-15faa228-05f5-436e-aa52-4c712bcdaf3b.png)

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Karl Godard 2023-02-24 16:44:26 -08:00 committed by GitHub
parent 834c8ca551
commit 308ebf64cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 422 additions and 170 deletions

View file

@ -21,7 +21,7 @@ export const selectors = i18n.translate('xpack.cloudDefend.controlSelectors', {
});
export const selectorsHelp = i18n.translate('xpack.cloudDefend.controlSelectorsHelp', {
defaultMessage: 'Create selectors to match on activities that should be blocked or alerted.',
defaultMessage: 'Create selectors to match on operations that should be blocked or alerted.',
});
export const responses = i18n.translate('xpack.cloudDefend.controlResponses', {
@ -99,6 +99,22 @@ export const errorValueLengthExceeded = i18n.translate(
}
);
export const getConditionHelpLabel = (prop: string) => {
switch (prop) {
case ControlSelectorCondition.ignoreVolumeMounts:
return i18n.translate('xpack.cloudDefend.ignoreVolumeMountsHelp', {
defaultMessage: 'Ignore operations on all volume mounts.',
});
case ControlSelectorCondition.ignoreVolumeFiles:
return i18n.translate('xpack.cloudDefend.ignoreVolumeFilesHelp', {
defaultMessage:
'Ignore operations on file mounts only. e.g mounted files, configMaps, secrets etc...',
});
default:
return '';
}
};
export const getConditionLabel = (prop: string) => {
switch (prop) {
case ControlSelectorCondition.operation:
@ -117,6 +133,14 @@ export const getConditionLabel = (prop: string) => {
return i18n.translate('xpack.cloudDefend.targetFilePath', {
defaultMessage: 'Target file path',
});
case ControlSelectorCondition.ignoreVolumeFiles:
return i18n.translate('xpack.cloudDefend.ignoreVolumeFiles', {
defaultMessage: 'Ignore volume files',
});
case ControlSelectorCondition.ignoreVolumeMounts:
return i18n.translate('xpack.cloudDefend.ignoreVolumeMounts', {
defaultMessage: 'Ignore volume mounts',
});
case ControlSelectorCondition.orchestratorClusterId:
return i18n.translate('xpack.cloudDefend.orchestratorClusterId', {
defaultMessage: 'Orchestrator cluster ID',
@ -141,5 +165,7 @@ export const getConditionLabel = (prop: string) => {
return i18n.translate('xpack.cloudDefend.orchestratorResourceType', {
defaultMessage: 'Orchestrator resource type',
});
default:
return '';
}
};

View file

@ -118,6 +118,21 @@ describe('<ControlGeneralViewSelector />', () => {
expect(updatedOptions[0]).not.toHaveTextContent('containerImageName');
});
it('allows the user add boolean type conditions', async () => {
const { getByTestId, rerender } = render(<WrappedComponent />);
const addConditionBtn = getByTestId('cloud-defend-btnaddselectorcondition');
userEvent.click(addConditionBtn);
const addIgnoreVolumeMounts = getByTestId('cloud-defend-addmenu-ignoreVolumeMounts');
await waitFor(() => userEvent.click(addIgnoreVolumeMounts));
const updatedSelector: ControlSelector = { ...onChange.mock.calls[0][0] };
rerender(<WrappedComponent selector={updatedSelector} />);
expect(updatedSelector.ignoreVolumeMounts).toBeTruthy();
});
it('shows an error if no conditions are added', async () => {
const { getByText, getByTestId, rerender } = render(<WrappedComponent />);

View file

@ -19,6 +19,7 @@ import {
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiText,
} from '@elastic/eui';
import { useStyles } from './styles';
import {
@ -26,6 +27,7 @@ import {
ControlFormErrorMap,
ControlSelectorCondition,
ControlSelectorConditionUIOptionsMap,
ControlSelectorBooleanConditions,
ControlSelector,
} from '../../types';
import * as i18n from '../control_general_view/translations';
@ -36,6 +38,104 @@ import {
MAX_FILE_PATH_VALUE_LENGTH_BYTES,
} from '../../common/constants';
interface ConditionProps {
label: string;
prop: string;
onRemoveCondition(prop: string): void;
}
interface StringArrayConditionProps extends ConditionProps {
selector: ControlSelector;
errorMap: ControlFormErrorMap;
onAddValueToCondition(prop: string, value: string): void;
onChangeStringArrayCondition(prop: string, value: string[]): void;
}
const BooleanCondition = ({ label, prop, onRemoveCondition }: ConditionProps) => {
return (
<EuiFormRow label={label} fullWidth={true} key={prop}>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem>
<EuiText size="s">
<p>
<small>{i18n.getConditionHelpLabel(prop)}</small>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
onClick={() => onRemoveCondition(prop)}
aria-label="Remove condition"
data-test-subj={'cloud-defend-btnremovecondition-' + prop}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};
const StringArrayCondition = ({
label,
prop,
selector,
errorMap,
onRemoveCondition,
onAddValueToCondition,
onChangeStringArrayCondition,
}: StringArrayConditionProps) => {
const values = selector[prop as keyof ControlSelector] as string[];
const selectedOptions =
values?.map((option) => {
return { label: option, value: option };
}) || [];
const restrictedValues = ControlSelectorConditionUIOptionsMap[prop]?.values;
return (
<EuiFormRow
label={label}
fullWidth={true}
key={prop}
isInvalid={!!errorMap.hasOwnProperty(prop)}
>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem>
<EuiComboBox
aria-label={label}
fullWidth={true}
onCreateOption={
!restrictedValues
? (searchValue) => onAddValueToCondition(prop, searchValue)
: undefined
}
selectedOptions={selectedOptions}
options={
restrictedValues
? restrictedValues.map((value: string) => ({ label: value, value }))
: selectedOptions
}
onChange={(options) =>
onChangeStringArrayCondition(prop, options.map((option) => option.value) as string[])
}
isClearable
data-test-subj={'cloud-defend-selectorcondition-' + prop}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
onClick={() => onRemoveCondition(prop)}
aria-label="Remove condition"
data-test-subj={'cloud-defend-btnremovecondition-' + prop}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};
/** main component */
export const ControlGeneralViewSelector = ({
selector,
selectors,
@ -121,7 +221,7 @@ export const ControlGeneralViewSelector = ({
[errorMap, index, conditionsAdded, onChange, selector, selectors]
);
const onChangeCondition = useCallback(
const onChangeStringArrayCondition = useCallback(
(prop: string, values: string[]) => {
const updatedSelector = { ...selector, [prop]: values };
const errors = [];
@ -156,12 +256,25 @@ export const ControlGeneralViewSelector = ({
[errorMap, index, conditionsAdded, onChange, selector]
);
const onChangeBooleanCondition = useCallback(
(prop: string, value: boolean) => {
const updatedSelector = { ...selector, [prop]: value };
onChange(updatedSelector, index);
},
[index, onChange, selector]
);
const onAddCondition = useCallback(
(prop: string) => {
onChangeCondition(prop, []);
if (prop in ControlSelectorBooleanConditions) {
onChangeBooleanCondition(prop, true);
} else {
onChangeStringArrayCondition(prop, []);
}
closeAddCondition();
},
[closeAddCondition, onChangeCondition]
[closeAddCondition, onChangeBooleanCondition, onChangeStringArrayCondition]
);
const onRemoveCondition = useCallback(
@ -185,10 +298,10 @@ export const ControlGeneralViewSelector = ({
const values = selector[prop as keyof ControlSelector] as string[];
if (values && values.indexOf(value) === -1) {
onChangeCondition(prop, [...values, value]);
onChangeStringArrayCondition(prop, [...values, value]);
}
},
[onChangeCondition, selector]
[onChangeStringArrayCondition, selector]
);
const errors = useMemo(() => {
@ -270,56 +383,31 @@ export const ControlGeneralViewSelector = ({
</EuiFormRow>
{Object.keys(selector).map((prop: string) => {
if (['name', 'hasErrors'].indexOf(prop) === -1) {
const values = selector[prop as keyof ControlSelector] as string[];
const selectedOptions =
values?.map((option) => {
return { label: option, value: option };
}) || [];
const label = i18n.getConditionLabel(prop);
const restrictedValues = ControlSelectorConditionUIOptionsMap[prop]?.values;
return (
<EuiFormRow
label={label}
fullWidth={true}
key={prop}
isInvalid={!!errorMap.hasOwnProperty(prop)}
>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem>
<EuiComboBox
aria-label={label}
fullWidth={true}
onCreateOption={
!restrictedValues
? (searchValue) => onAddValueToCondition(prop, searchValue)
: undefined
}
selectedOptions={selectedOptions}
options={
restrictedValues
? restrictedValues.map((value: string) => ({ label: value, value }))
: selectedOptions
}
onChange={(options) =>
onChangeCondition(prop, options.map((option) => option.value) as string[])
}
isClearable
data-test-subj={'cloud-defend-selectorcondition-' + prop}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
onClick={() => onRemoveCondition(prop)}
aria-label="Remove condition"
data-test-subj={'cloud-defend-btnremovecondition-' + prop}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
if (prop in ControlSelectorBooleanConditions) {
return (
<BooleanCondition
key={prop}
label={label}
prop={prop}
onRemoveCondition={onRemoveCondition}
/>
);
} else {
return (
<StringArrayCondition
key={prop}
label={label}
prop={prop}
selector={selector}
errorMap={errorMap}
onAddValueToCondition={onAddValueToCondition}
onChangeStringArrayCondition={onChangeStringArrayCondition}
onRemoveCondition={onRemoveCondition}
/>
);
}
}
})}
</EuiForm>
@ -345,7 +433,11 @@ export const ControlGeneralViewSelector = ({
size="s"
items={remainingProps.map((prop) => {
return (
<EuiContextMenuItem key={prop} onClick={() => onAddCondition(prop)}>
<EuiContextMenuItem
data-test-subj={`cloud-defend-addmenu-${prop}`}
key={prop}
onClick={() => onAddCondition(prop)}
>
{i18n.getConditionLabel(prop)}
</EuiContextMenuItem>
);

View file

@ -0,0 +1,190 @@
{
"$id": "https://elastic.co/cloud-defend/policy-schema.json",
"type": "object",
"required": ["selectors", "responses"],
"additionalProperties": false,
"properties": {
"selectors": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/selector"
}
},
"responses": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/response"
}
}
},
"$defs": {
"selector": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"anyOf": [
{
"required": ["operation"]
},
{
"required": ["containerImageName"]
},
{
"required": ["containerImageTag"]
},
{
"required": ["targetFilePath"]
},
{
"required": ["orchestratorClusterId"]
},
{
"required": ["orchestratorClusterName"]
},
{
"required": ["orchestratorNamespace"]
},
{
"required": ["orchestratorResourceLabel"]
},
{
"required": ["orchestratorResourceName"]
},
{
"required": ["orchestratorType"]
},
{
"required": ["ignoreVolumeMounts"]
},
{
"required": ["ignoreVolumeFiles"]
}
],
"properties": {
"name": {
"type": "string"
},
"operation": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"createExecutable",
"modifyExecutable",
"createFile",
"modifyFile",
"deleteFile"
]
}
},
"containerImageName": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"containerImageTag": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"targetFilePath": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorClusterId": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorClusterName": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorNamespace": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorResourceLabel": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorResourceName": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"orchestratorType": {
"type": "array",
"minItems": 1,
"items": {
"enum": ["kubernetes"]
}
},
"ignoreVolumeMounts": {
"type": "boolean",
"description": "Ignore all volume mounts. e.g directories, files, configMaps, secrets etc...\nNote: should not be used with ignoreVolumeFiles"
},
"ignoreVolumeFiles": {
"type": "boolean",
"description": "Ignore file mounts. e.g files, configMaps, secrets\nNote: should not be used with ignoreVolumeMounts"
}
},
"dependencies": {
"ignoreVolumeMounts": {
"not": {
"required": ["ignoreVolumeFiles"]
}
}
}
},
"response": {
"type": "object",
"required": ["match", "actions"],
"additionalProperties": false,
"properties": {
"match": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"exclude": {
"type": "array",
"items": {
"type": "string"
}
},
"actions": {
"type": "array",
"minItems": 1,
"items": {
"enum": ["alert", "block"]
}
}
}
}
}
}

View file

@ -8,7 +8,13 @@ import { useMemo } from 'react';
import yaml from 'js-yaml';
import { setDiagnosticsOptions } from 'monaco-yaml';
import { monaco } from '@kbn/monaco';
import { MAX_CONDITION_VALUE_LENGTH } from '../../../common/constants';
/**
* In order to keep this json in sync with https://github.com/elastic/cloud-defend/blob/main/modules/service/policy-schema.json
* Do NOT commit edits to policy_schema.json as part of a PR. Please make the changes in the cloud-defend repo and use the
* make push-policy-schema-kibana command to automate the creation of a PR to sync the changes.
*/
import policySchemaJson from './policy_schema.json';
const { Uri, editor } = monaco;
@ -24,8 +30,25 @@ export const useConfigModel = (configuration: string) => {
}
}, [configuration]);
// creating a string csv to avoid the next useMemo from re-running regardless of whether
// selector names changed or not.
const selectorNamesCSV = useMemo(
() => json?.selectors?.map((selector: any) => selector.name).join(',') || '',
[json?.selectors]
);
return useMemo(() => {
const selectorNames = json?.selectors?.map((selector: any) => selector.name) || [];
const schema: any = { ...policySchemaJson };
// dynamically setting enum values for response match and exclude properties.
if (schema.$defs.response.properties.match.items) {
const responseProps = schema.$defs.response.properties;
const selectorEnum = { enum: selectorNamesCSV.split(',') };
responseProps.match.items = selectorEnum;
responseProps.exclude.items = selectorEnum;
} else {
throw new Error('cloud_defend json schema is invalid');
}
setDiagnosticsOptions({
validate: true,
@ -35,112 +58,7 @@ export const useConfigModel = (configuration: string) => {
{
uri: SCHEMA_URI,
fileMatch: [String(modelUri)],
schema: {
type: 'object',
required: ['selectors', 'responses'],
additionalProperties: false,
properties: {
selectors: {
type: 'array',
minItems: 1,
items: { $ref: '#/$defs/selector' },
},
responses: {
type: 'array',
minItems: 1,
items: { $ref: '#/$defs/response' },
},
},
$defs: {
selector: {
type: 'object',
required: ['name'],
additionalProperties: false,
anyOf: [
{ required: ['operation'] },
{ required: ['containerImageName'] },
{ required: ['containerImageTag'] },
{ required: ['targetFilePath'] },
{ required: ['orchestratorClusterId'] },
{ required: ['orchestratorClusterName'] },
{ required: ['orchestratorNamespace'] },
{ required: ['orchestratorResourceLabel'] },
{ required: ['orchestratorResourceName'] },
{ required: ['orchestratorType'] },
],
properties: {
name: {
type: 'string',
maxLength: MAX_CONDITION_VALUE_LENGTH,
},
operation: {
type: 'array',
minItems: 1,
items: { enum: ['createExecutable', 'modifyExecutable', 'execMemFd'] },
},
containerImageName: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
containerImageTag: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
targetFilePath: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorClusterId: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorClusterName: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorNamespace: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorResourceLabel: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorResourceName: {
type: 'array',
minItems: 1,
items: { type: 'string', maxLength: MAX_CONDITION_VALUE_LENGTH },
},
orchestratorType: {
type: 'array',
minItems: 1,
items: { enum: ['kubernetes'] },
},
},
},
response: {
type: 'object',
required: ['match', 'actions'],
additionalProperties: false,
properties: {
match: { type: 'array', minItems: 1, items: { enum: selectorNames } },
exclude: { type: 'array', items: { enum: selectorNames } },
actions: {
type: 'array',
minItems: 1,
items: { enum: ['alert', 'block'] },
},
},
},
},
},
schema,
},
],
});
@ -148,9 +66,9 @@ export const useConfigModel = (configuration: string) => {
let model = editor.getModel(modelUri);
if (model === null) {
model = editor.createModel(configuration, 'yaml', modelUri);
model = editor.createModel('', 'yaml', modelUri);
}
return model;
}, [configuration, json?.selectors]);
}, [selectorNamesCSV]);
};

View file

@ -8,10 +8,10 @@
import { i18n } from '@kbn/i18n';
export const enableControl = i18n.translate('xpack.cloudDefend.enableControl', {
defaultMessage: 'Enable BPF/LSM controls',
defaultMessage: 'Enable drift prevention',
});
export const enableControlHelp = i18n.translate('xpack.cloudDefend.enableControlHelp', {
defaultMessage:
'Enables BPF/LSM control mechanism, for use with FIM and container drift prevention.',
'Toggles enablement of drift prevention policy to alert and/or block file operations.',
});

View file

@ -30,6 +30,8 @@ export enum ControlSelectorCondition {
containerImageName = 'containerImageName',
containerImageTag = 'containerImageTag',
targetFilePath = 'targetFilePath',
ignoreVolumeFiles = 'ignoreVolumeFiles',
ignoreVolumeMounts = 'ignoreVolumeMounts',
orchestratorClusterId = 'orchestratorClusterId',
orchestratorClusterName = 'orchestratorClusterName',
orchestratorNamespace = 'orchestratorNamespace',
@ -39,6 +41,11 @@ export enum ControlSelectorCondition {
orchestratorType = 'orchestratorType',
}
export enum ControlSelectorBooleanConditions {
ignoreVolumeFiles = 'ignoreVolumeFiles',
ignoreVolumeMounts = 'ignoreVolumeMounts',
}
export enum ControlSelectorOperation {
createExecutable = 'createExecutable',
modifyExecutable = 'modifyExecutable',
@ -66,6 +73,8 @@ export interface ControlSelector {
containerImageName?: string[];
containerImageTag?: string[];
targetFilePath?: string[];
ignoreVolumeFiles?: boolean;
ignoreVolumeMounts?: boolean;
orchestratorClusterId?: string[];
orchestratorClusterName?: string[];
orchestratorNamespace?: string[];

View file

@ -1,12 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
"common/**/*",
"public/**/*",
"../../../typings/**/*"
"../../../typings/**/*",
"server/**/*.json",
"public/**/*.json"
],
"kbn_references": [
"@kbn/core",
@ -21,6 +23,6 @@
"@kbn/shared-ux-router"
],
"exclude": [
"target/**/*",
"target/**/*"
]
}