Skip to content

Commit

Permalink
[D4C] policy schema/UI refactored to support process and file selecto…
Browse files Browse the repository at this point in the history
…rs (network soon) (#153126)

## Summary

This PR refactors alot of the interface/type definitions around
"cloud_defend/control" selectors and responses. A lot of refactoring
went into ensuring the interfaces and types that represent file and
process selector/responses in the UI is as type safe as possible. It
should take fewer changes to add new conditions, and compile time checks
should ensure most code paths are updated correctly.

Updates to policy_schema.json (json-schema) made to support the
following yaml schema format:
```
file:
  selectors:
    - name: nginxBinMods
      operation:
        - createExecutable
        - modifyExecutable
      targetFilePath:
        - /usr/bin/**
      containerImageName:
        - nginx
    - name: excludeTestServers
      containerImageTag:
        - staging
        - preprod
  responses:
    - match:
        - nginxBinMods
      exclude:
        - excludeTestServers
      actions:
        - alert
process:
  selectors:
    - name: allProcesses
      operation:
        - fork
        - exec
  responses:
    - match:
        - allProcesses
      actions:
        - log
```

Both selectors and responses now ask for a "type" to be selected when
adding. This locks it into either a process or file selector/response
type. Certain conditions are available to specfiic types.

### TODOS
- more unit tests to cover new UX features
- cloud_defend integration package needs to be updated with new defaults
for configuration
- i18n copy could use PM/Techwriter review

### Screenshot

![image](https://user-images.githubusercontent.com/16198204/224398453-e41d8bf7-e952-46f4-9cd9-340c4928ad7e.png)

### Checklist

- [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)
  • Loading branch information
mitodrummer authored Mar 10, 2023
1 parent 15f1f64 commit 6552165
Show file tree
Hide file tree
Showing 17 changed files with 1,528 additions and 653 deletions.
2 changes: 1 addition & 1 deletion x-pack/plugins/cloud_defend/public/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
export const DEFAULT_VISIBLE_ROWS_PER_PAGE = 10; // generic default # of table rows to show (currently we only have a list of policies)
export const LOCAL_STORAGE_PAGE_SIZE = 'cloudDefend:userPageSize';
export const VALID_SELECTOR_NAME_REGEX = /^[a-z0-9][a-z0-9_\-]+$/i; // alphanumberic (no - or _ allowed on first char)
export const VALID_SELECTOR_NAME_REGEX = /^[a-z0-9][a-z0-9_\-]*$/i; // alphanumberic (no - or _ allowed on first char)
export const MAX_SELECTOR_NAME_LENGTH = 128; // chars
export const MAX_CONDITION_VALUE_LENGTH_BYTES = 511;
export const MAX_FILE_PATH_VALUE_LENGTH_BYTES = 255;
Expand Down
108 changes: 108 additions & 0 deletions x-pack/plugins/cloud_defend/public/common/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 {
getSelectorsAndResponsesFromYaml,
getYamlFromSelectorsAndResponses,
getSelectorConditions,
conditionCombinationInvalid,
getRestrictedValuesForCondition,
} from './utils';
import { MOCK_YAML_CONFIGURATION, MOCK_YAML_INVALID_CONFIGURATION } from '../test/mocks';

describe('getSelectorsAndResponsesFromYaml', () => {
it('converts yaml into arrays of selectors and responses', () => {
const { selectors, responses } = getSelectorsAndResponsesFromYaml(MOCK_YAML_CONFIGURATION);

expect(selectors).toHaveLength(3);
expect(responses).toHaveLength(2);
});

it('returns empty arrays if bad yaml', () => {
const { selectors, responses } = getSelectorsAndResponsesFromYaml(
MOCK_YAML_INVALID_CONFIGURATION
);

expect(selectors).toHaveLength(0);
expect(responses).toHaveLength(0);
});
});

describe('getYamlFromSelectorsAndResponses', () => {
it('converts arrays of selectors and responses into yaml', () => {
const { selectors, responses } = getSelectorsAndResponsesFromYaml(MOCK_YAML_CONFIGURATION);
const yaml = getYamlFromSelectorsAndResponses(selectors, responses);
expect(yaml).toEqual(MOCK_YAML_CONFIGURATION);
});
});

describe('getSelectorConditions', () => {
it('grabs file conditions for file selectors', () => {
const options = getSelectorConditions('file');

// check at least one common condition present
expect(options.includes('containerImageName')).toBeTruthy();

// check file specific conditions present
expect(options.includes('ignoreVolumeFiles')).toBeTruthy();
expect(options.includes('ignoreVolumeMounts')).toBeTruthy();
expect(options.includes('targetFilePath')).toBeTruthy();

// check that process specific conditions are not included
expect(options.includes('processExecutable')).toBeFalsy();
expect(options.includes('processName')).toBeFalsy();
});

it('grabs process conditions for process selectors', () => {
const options = getSelectorConditions('process');

// check at least one common condition present
expect(options.includes('containerImageName')).toBeTruthy();

// check file specific conditions present
expect(options.includes('ignoreVolumeFiles')).toBeFalsy();
expect(options.includes('ignoreVolumeMounts')).toBeFalsy();
expect(options.includes('targetFilePath')).toBeFalsy();

// check that process specific conditions are not included
expect(options.includes('processExecutable')).toBeTruthy();
expect(options.includes('processName')).toBeTruthy();
expect(options.includes('processUserName')).toBeTruthy();
expect(options.includes('processUserId')).toBeTruthy();
expect(options.includes('sessionLeaderInteractive')).toBeTruthy();
});
});

describe('conditionCombinationInvalid', () => {
it('returns true when conditions cannot be combined', () => {
const result = conditionCombinationInvalid(['ignoreVolumeMounts'], 'ignoreVolumeFiles');

expect(result).toBeTruthy();
});

it('returns false when they can', () => {
const result = conditionCombinationInvalid(['containerImageName'], 'ignoreVolumeFiles');

expect(result).toBeFalsy();
});
});

describe('getRestrictedValuesForCondition', () => {
it('works', () => {
let values = getRestrictedValuesForCondition('file', 'operation');
expect(values).toEqual([
'createExecutable',
'modifyExecutable',
'createFile',
'modifyFile',
'deleteFile',
]);

values = getRestrictedValuesForCondition('process', 'operation');
expect(values).toEqual(['fork', 'exec']);
});
});
150 changes: 149 additions & 1 deletion x-pack/plugins/cloud_defend/public/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,157 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import yaml from 'js-yaml';
import { NewPackagePolicy } from '@kbn/fleet-plugin/public';
import {
Selector,
Response,
SelectorType,
DefaultFileSelector,
DefaultProcessSelector,
DefaultFileResponse,
DefaultProcessResponse,
SelectorConditionsMap,
SelectorCondition,
} from '../types';

export function getInputFromPolicy(policy: NewPackagePolicy, inputId: string) {
return policy.inputs.find((input) => input.type === inputId);
}

export function getSelectorTypeIcon(type: SelectorType) {
switch (type) {
case 'process':
return 'gear';
case 'file':
default:
return 'document';
}
}

export function camelToSentenceCase(prop: string) {
const sentence = prop.replace(/([A-Z])/g, ' $1').toLowerCase();
return sentence[0].toUpperCase() + sentence.slice(1);
}

export function conditionCombinationInvalid(
addedConditions: SelectorCondition[],
condition: SelectorCondition
): boolean {
const options = SelectorConditionsMap[condition];
const invalid = addedConditions.find((added) => {
return options?.not?.includes(added);
});

return !!invalid;
}

export function getRestrictedValuesForCondition(
type: SelectorType,
condition: SelectorCondition
): string[] | undefined {
const options = SelectorConditionsMap[condition];

if (Array.isArray(options.values)) {
return options.values;
}

if (options?.values?.[type]) {
return options.values[type];
}
}

export function getSelectorConditions(type: SelectorType): SelectorCondition[] {
const allConditions = Object.keys(SelectorConditionsMap) as SelectorCondition[];
return allConditions.filter((key) => {
const options = SelectorConditionsMap[key];
return !options.selectorType || options.selectorType === type;
});
}

export function getDefaultSelectorByType(type: SelectorType): Selector {
switch (type) {
case 'process':
return { ...DefaultProcessSelector };
case 'file':
default:
return { ...DefaultFileSelector };
}
}

export function getDefaultResponseByType(type: SelectorType): Response {
switch (type) {
case 'process':
return { ...DefaultProcessResponse };
case 'file':
default:
return { ...DefaultFileResponse };
}
}

export function getSelectorsAndResponsesFromYaml(configuration: string): {
selectors: Selector[];
responses: Response[];
} {
let selectors: Selector[] = [];
let responses: Response[] = [];

try {
const result = yaml.load(configuration);

if (result) {
// iterate selector/response types
Object.keys(result).forEach((selectorType) => {
const obj = result[selectorType];

if (obj.selectors) {
selectors = selectors.concat(
obj.selectors.map((selector: any) => ({ ...selector, type: selectorType }))
);
}

if (obj.responses) {
responses = responses.concat(
obj.responses.map((response: any) => ({ ...response, type: selectorType }))
);
}
});
}
} catch {
// noop
}
return { selectors, responses };
}

export function getYamlFromSelectorsAndResponses(selectors: Selector[], responses: Response[]) {
const schema: any = {};

selectors.reduce((current, selector: any) => {
if (current && selector) {
if (current[selector.type]) {
current[selector.type]?.selectors.push(selector);
} else {
current[selector.type] = { selectors: [selector], responses: [] };
}
}

// the 'any' cast is used so we can keep 'selector.type' type safe
delete selector.type;

return current;
}, schema);

responses.reduce((current, response: any) => {
if (current && response && response.type) {
if (current[response.type]) {
current[response.type]?.responses.push(response);
}
}

delete response.type;

return current;
}, schema);

return yaml.dump(schema);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,20 @@ describe('<ControlGeneralView />', () => {
try {
const json = yaml.load(configuration);

expect(json.selectors.length).toBe(getAllByTestId('cloud-defend-selector').length);
expect(json.responses.length).toBe(getAllByTestId('cloud-defend-response').length);
expect(json.selectors.length).toBe(3);
expect(json.responses.length).toBe(2);
expect(json.file.selectors.length).toBe(getAllByTestId('cloud-defend-selector').length);
expect(json.file.responses.length).toBe(getAllByTestId('cloud-defend-response').length);
expect(json.file.selectors.length).toBe(3);
expect(json.file.responses.length).toBe(2);
} catch (err) {
throw err;
}
});

it('allows a user to add a new selector and new response', async () => {
it('allows a user to add a new selector', async () => {
const { getAllByTestId, getByTestId, rerender } = render(<WrappedComponent />);

userEvent.click(getByTestId('cloud-defend-btnaddselector'));
userEvent.click(getByTestId('cloud-defend-btnaddresponse'));
userEvent.click(getByTestId('cloud-defend-btnAddSelector'));
await waitFor(() => userEvent.click(getByTestId('cloud-defend-btnAddFileSelector')));

const policy = onChange.mock.calls[0][0].updatedPolicy;

Expand All @@ -67,10 +67,29 @@ describe('<ControlGeneralView />', () => {
try {
const json = yaml.load(configuration);

expect(json.selectors.length).toBe(getAllByTestId('cloud-defend-selector').length);
expect(json.responses.length).toBe(getAllByTestId('cloud-defend-response').length);
expect(json.selectors.length).toBe(4);
expect(json.responses.length).toBe(3);
expect(json.file.selectors.length).toBe(getAllByTestId('cloud-defend-selector').length);
} catch (err) {
throw err;
}
});

it('allows a user to add a new response', async () => {
const { getAllByTestId, getByTestId, rerender } = render(<WrappedComponent />);

userEvent.click(getByTestId('cloud-defend-btnAddResponse'));
await waitFor(() => userEvent.click(getByTestId('cloud-defend-btnAddFileResponse')));

const policy = onChange.mock.calls[0][0].updatedPolicy;

rerender(<WrappedComponent policy={policy} />);

const input = getInputFromPolicy(policy, INPUT_CONTROL);
const configuration = input?.vars?.configuration?.value;

try {
const json = yaml.load(configuration);

expect(json.file.responses.length).toBe(getAllByTestId('cloud-defend-response').length);
} catch (err) {
throw err;
}
Expand Down
Loading

0 comments on commit 6552165

Please sign in to comment.