[Security Solution][Detections] Extended rule execution logging to Event Log (#126063)

**Epics:** https://github.com/elastic/kibana/issues/124947, https://github.com/elastic/kibana/issues/118324
**Fixes:** https://github.com/elastic/kibana/issues/131352

## Summary

Console logs written by rule executors can now be "routed" to the Event Log in addition to the console. A new table UI for viewing plain rule execution logs allows the user to look at all status changes, errors, warnings, info and debug messages on the Rule Details page.

<img width="1502" alt="Screenshot 2022-07-20 at 15 31 54" src="https://user-images.githubusercontent.com/7359339/179995075-24440224-daf9-4e73-bc62-b6ce211052b3.png">

**This feature is hidden under a feature flag and disabled by default** -- it might not be production-ready yet. We will need to work on product and UX/UI design in the subsequent development cycles to make it ready for release. Until then, we can start using it in development: it should make it easier to troubleshoot issues with rule execution. Add this flag to your Kibana config to enable this feature:

```yaml
xpack.securitySolution.enableExperimental: ['extendedRuleExecutionLoggingEnabled']
```

If the flag is enabled:

- Rules will start writing console logs to Event Log as events of a new type `message`, in addition to the existing `status-change` and `execution-metrics` events.
- Rule Details page will show a new tab called `Execution events`. This tab will contain a table with plain execution logs.
- In Stack Management, you will find two new Kibana Advanced Settings for controlling this extended logging.

As for the new Kibana Advanced Settings, by default:

- Extended logging is enabled.
- The minimum console log level to be written to Event Log is `error`. This only affects the new `message` events.

<img width="774" alt="Screenshot 2022-07-20 at 15 41 29" src="https://user-images.githubusercontent.com/7359339/179997070-d86dfc6b-3862-49ff-879d-ecc30bc128d7.png">

## Implementation details

**Important change**: refactored the folder structure as our first step to **domain-driven architecture** and **splitting the Detection Engine into subdomains**.

- Extracted most of the code related to Rule Execution Log and Rule Monitoring in general into a subdomain called `rule_monitoring`. This subdomain now lives in three folders:
  - `security_solution/common/detection_engine/rule_monitoring`
  - `security_solution/public/detection_engine/rule_monitoring`
  - `security_solution/server/lib/detection_engine/rule_monitoring`
- Tried to create a developer-friendly and clear folder structure within the subdomain.

Other changes:

- Changed all rule executors to write console logs via an instance of `IRuleExecutionLogForExecutors` instead of the console `Logger`.
  - `IRuleExecutionLogForExecutors` is passed to rule executors and downstream functions they call.
  - `Logger` is not passed anymore.
  - `buildRuleMessage` and `buildRuleMessageFactory` are deleted.
- Added support for writing console logs to Event Log.
  - Added a new rule execution event type `message` for writing console logs to Event Log.
  - Every rule execution event now has a `log.level` and `event.severity`.
- Improved the format of console logs written by rules.
  - Created a child logger for console logs of rule executors: `plugins.securitySolution.ruleExecution`.
  - Added rule static “signature” ID (`rule.rule_id`) as a correlation id to the logs.
  - Cleaned up the formatting of console logs.
  - Fixed `ExtMeta` to use interfaces instead of type intersection due to a found [bug](https://github.com/microsoft/TypeScript/issues/47935) in TypeScript that affected this type.
- Made changes in the domain model.
  - Renamed the `AggregateRuleExecutionEvent` into the `RuleExecutionResult`.
  - The new plain event is called `RuleExecutionEvent`.
- Finalized the API endpoint for fetching plain execution logs.
- Built a Rule Execution Events Table UI for showing and filtering plain execution logs.
  - Did some refactoring to extract reusable components/hooks to make development of tables easier in the future.

## Execution events table UI

For context, this is how the existing `Execution logs` table looks like when the flag is off (notice the renaming to `Execution results`:

<img width="1506" alt="Screenshot 2022-07-20 at 15 29 04" src="https://user-images.githubusercontent.com/7359339/179994450-45121035-ebb0-4e6f-83c0-9cbbbbd0b598.png">

This is the new `Execution events` table when the flag is on:

<img width="1502" alt="Screenshot 2022-07-20 at 15 31 54" src="https://user-images.githubusercontent.com/7359339/179995075-24440224-daf9-4e73-bc62-b6ce211052b3.png">

Showing only trace and debug events:

<img width="1505" alt="Screenshot 2022-07-20 at 15 33 48" src="https://user-images.githubusercontent.com/7359339/179995484-d97ff7e3-2756-42db-802f-41f11bd37507.png">

Showing only status changes:

<img width="1507" alt="Screenshot 2022-07-20 at 15 35 04" src="https://user-images.githubusercontent.com/7359339/179995804-ca6808b7-3b47-411b-a74e-d141b3fd74e0.png">

Showing only warning and error `message`s:

<img width="1508" alt="Screenshot 2022-07-20 at 15 37 11" src="https://user-images.githubusercontent.com/7359339/179996258-c154b95d-642d-45a6-b19a-7185cd71f295.png">

Expanded rows showing details of the corresponding events:

<img width="1452" alt="Screenshot 2022-07-20 at 15 39 16" src="https://user-images.githubusercontent.com/7359339/179996771-3954ceea-24e9-4760-9103-2daf6cb7b528.png">

<img width="1449" alt="Screenshot 2022-07-20 at 15 39 56" src="https://user-images.githubusercontent.com/7359339/179996805-c866674d-09a1-42ec-b954-58c6829ef19b.png">

## Console logs

Example:

```
[2022-02-23T17:05:09.901+03:00][DEBUG][plugins.securitySolution.ruleExecution] [+] Starting Signal Rule execution [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:09.907+03:00][DEBUG][plugins.securitySolution.ruleExecution] interval: 5m [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:09.908+03:00][INFO ][plugins.securitySolution.ruleExecution] Changing rule status to "running" [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:10.595+03:00][WARN ][plugins.securitySolution.ruleExecution] This rule is attempting to query data from Elasticsearch indices listed in the "Index pattern" section of the rule definition, however no index matching: ["logs-endpoint.alerts-*"] was found. This warning will continue to appear until a matching index is created or this rule is de-activated. If you have recently enrolled agents enabled with Endpoint Security through Fleet, this warning should stop once an alert is sent from an agent. [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:10.595+03:00][WARN ][plugins.securitySolution.ruleExecution] Changing rule status to "partial failure" [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.630+03:00][DEBUG][plugins.securitySolution.ruleExecution] sortIds: undefined [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.634+03:00][DEBUG][plugins.securitySolution.ruleExecution] totalHits: 0 [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.634+03:00][DEBUG][plugins.securitySolution.ruleExecution] searchResult.hit.hits.length: 0 [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.635+03:00][DEBUG][plugins.securitySolution.ruleExecution] totalHits was 0, exiting early [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.636+03:00][DEBUG][plugins.securitySolution.ruleExecution] [+] completed bulk index of 0 [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.636+03:00][DEBUG][plugins.securitySolution.ruleExecution] [+] Signal Rule execution completed. [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.638+03:00][DEBUG][plugins.securitySolution.ruleExecution] [+] Finished indexing 0 signals into .alerts-security.alerts [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
[2022-02-23T17:05:11.639+03:00][DEBUG][plugins.securitySolution.ruleExecution] [+] Finished indexing 0 signals searched between date ranges [
  {
    "to": "2022-02-23T14:05:09.775Z",
    "from": "2022-02-23T13:55:09.775Z",
    "maxSignals": 10000
  }
] [siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]
```

Note that:

- The logger name is now `plugins.securitySolution.ruleExecution`, which allows to turn on _only_ rule execution logs in the config (could be useful when debugging).
- Every log message has a suffix with correlation ids: `[siem.queryRule][Endpoint Security][rule id 825b2fab-8b3e-11ec-a4a0-cf820453283c][rule uuid 9a1a2dae-0b5f-4c3d-8305-a268d404c306][exec id ebb7f713-b216-4c90-a456-6c1a6815a065][space default]`


### 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] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
  - [x] `x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/README.md`
  - [x] Various JSDoc comments
- [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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] 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] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] 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))
- [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Georgii Gorbachev 2022-07-25 22:09:17 +02:00 committed by GitHub
parent 45db88e0e6
commit becaec81e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
239 changed files with 6524 additions and 3099 deletions

View file

@ -0,0 +1,214 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { defaultCsvArray } from '.';
describe('defaultCsvArray', () => {
describe('Creates a schema of an array that works in the following way:', () => {
type TestType = t.TypeOf<typeof TestType>;
const TestType = t.union(
[t.literal('foo'), t.literal('bar'), t.literal('42'), t.null, t.undefined],
'TestType'
);
const TestCsvArray = defaultCsvArray(TestType);
describe('Name of the schema', () => {
it('has a default value', () => {
const CsvArray = defaultCsvArray(TestType);
expect(CsvArray.name).toEqual('DefaultCsvArray<TestType>');
});
it('can be overriden', () => {
const CsvArray = defaultCsvArray(TestType, 'CustomName');
expect(CsvArray.name).toEqual('CustomName');
});
});
describe('Validation succeeds', () => {
describe('when input is a single valid string value', () => {
const cases = [{ input: 'foo' }, { input: 'bar' }, { input: '42' }];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = [input]; // note that it's an array after decode
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
describe('when input is an array of valid string values', () => {
const cases = [
{ input: ['foo'] },
{ input: ['foo', 'bar'] },
{ input: ['foo', 'bar', '42'] },
];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = input;
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
describe('when input is a string which is a comma-separated array of valid values', () => {
const cases = [
{
input: 'foo,bar',
expectedOutput: ['foo', 'bar'],
},
{
input: 'foo,bar,42',
expectedOutput: ['foo', 'bar', '42'],
},
];
cases.forEach(({ input, expectedOutput }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
describe('Validation fails', () => {
describe('when input is a single invalid value', () => {
const cases = [
{
input: 'val',
expectedErrors: ['Invalid value "val" supplied to "DefaultCsvArray<TestType>"'],
},
{
input: '5',
expectedErrors: ['Invalid value "5" supplied to "DefaultCsvArray<TestType>"'],
},
{
input: 5,
expectedErrors: ['Invalid value "5" supplied to "DefaultCsvArray<TestType>"'],
},
{
input: {},
expectedErrors: ['Invalid value "{}" supplied to "DefaultCsvArray<TestType>"'],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
describe('when input is an array of invalid values', () => {
const cases = [
{
input: ['value 1', 5],
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "5" supplied to "DefaultCsvArray<TestType>"',
],
},
{
input: ['value 1', 'foo'],
expectedErrors: ['Invalid value "value 1" supplied to "DefaultCsvArray<TestType>"'],
},
{
input: ['', 5, {}],
expectedErrors: [
'Invalid value "" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "5" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "{}" supplied to "DefaultCsvArray<TestType>"',
],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
describe('when input is a string which is a comma-separated array of invalid values', () => {
const cases = [
{
input: 'value 1,5',
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "5" supplied to "DefaultCsvArray<TestType>"',
],
},
{
input: 'value 1,foo',
expectedErrors: ['Invalid value "value 1" supplied to "DefaultCsvArray<TestType>"'],
},
{
input: ',5,{}',
expectedErrors: [
'Invalid value "" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "5" supplied to "DefaultCsvArray<TestType>"',
'Invalid value "{}" supplied to "DefaultCsvArray<TestType>"',
],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
});
describe('Validation returns default value (an empty array)', () => {
describe('when input is', () => {
const cases = [{ input: null }, { input: undefined }, { input: '' }, { input: [] }];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = TestCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput: string[] = [];
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
/**
* Creates a schema of an array that works in the following way:
* - If input is a CSV string, it will be parsed to an array which will be validated.
* - If input is an array, each item is validated to match `itemSchema`.
* - If input is a single string, it is validated to match `itemSchema`.
* - If input is not specified, the result will be set to [] (empty array):
* - null, undefined, empty string, empty array
*
* In all cases when an input is valid, the resulting decoded value will be an array,
* either an empty one or containing valid items.
*
* @param itemSchema Schema of the array's items.
* @param name (Optional) Name of the resulting schema.
*/
export const defaultCsvArray = <TItem>(
itemSchema: t.Type<TItem>,
name?: string
): t.Type<TItem[]> => {
return new t.Type<TItem[]>(
name ?? `DefaultCsvArray<${itemSchema.name}>`,
t.array(itemSchema).is,
(input, context): Either<t.Errors, TItem[]> => {
if (input == null) {
return t.success([]);
} else if (typeof input === 'string') {
if (input === '') {
return t.success([]);
} else {
return t.array(itemSchema).validate(input.split(','), context);
}
} else {
return t.array(itemSchema).validate(input, context);
}
},
t.identity
);
};

View file

@ -0,0 +1,106 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { defaultValue } from '.';
describe('defaultValue', () => {
describe('Creates a schema that sets a default value if the input value is not specified', () => {
type TestType = t.TypeOf<typeof TestType>;
const TestType = t.union([t.string, t.number, t.null, t.undefined], 'TestType');
const DefaultValue = defaultValue(TestType, 42);
describe('Name of the schema', () => {
it('has a default value', () => {
expect(defaultValue(TestType, 42).name).toEqual('DefaultValue<TestType>');
});
it('can be overriden', () => {
expect(defaultValue(TestType, 42, 'CustomName').name).toEqual('CustomName');
});
});
describe('Validation succeeds', () => {
describe('when input is a valid value', () => {
const cases = [
{ input: 'foo' },
{ input: '42' },
{ input: 42 },
// including all "falsey" values which are not null or undefined
{ input: '' },
{ input: 0 },
];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultValue.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = input;
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
describe('Validation fails', () => {
describe('when input is an invalid value', () => {
const cases = [
{
input: {},
expectedErrors: ['Invalid value "{}" supplied to "DefaultValue<TestType>"'],
},
{
input: { foo: 42 },
expectedErrors: ['Invalid value "{"foo":42}" supplied to "DefaultValue<TestType>"'],
},
{
input: [],
expectedErrors: ['Invalid value "[]" supplied to "DefaultValue<TestType>"'],
},
{
input: ['foo', 42],
expectedErrors: ['Invalid value "["foo",42]" supplied to "DefaultValue<TestType>"'],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = DefaultValue.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
});
describe('Validation returns specified default value', () => {
describe('when input is', () => {
const cases = [{ input: null }, { input: undefined }];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultValue.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = 42;
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import type { Either } from 'fp-ts/lib/Either';
/**
* Creates a schema that sets a default value if the input value is not specified.
*
* @param valueSchema Base schema of a value.
* @param value Default value to set.
* @param name (Optional) Name of the resulting schema.
*/
export const defaultValue = <TValue>(
valueSchema: t.Type<TValue>,
value: TValue,
name?: string
): t.Type<TValue> => {
return new t.Type<TValue>(
name ?? `DefaultValue<${valueSchema.name}>`,
valueSchema.is,
(input, context): Either<t.Errors, TValue> =>
input == null ? t.success(value) : valueSchema.validate(input, context),
t.identity
);
};

View file

@ -9,10 +9,12 @@
export * from './default_array';
export * from './default_boolean_false';
export * from './default_boolean_true';
export * from './default_csv_array';
export * from './default_empty_string';
export * from './default_string_array';
export * from './default_string_boolean_false';
export * from './default_uuid';
export * from './default_value';
export * from './default_version_number';
export * from './empty_string_array';
export * from './enumeration';

View file

@ -32,6 +32,7 @@ const optionalDateFieldSchema = schema.maybe(
const sortSchema = schema.object({
sort_field: schema.oneOf([
schema.literal('@timestamp'),
schema.literal('event.sequence'), // can be used as a tiebreaker for @timestamp
schema.literal('event.start'),
schema.literal('event.end'),
schema.literal('event.provider'),

View file

@ -231,6 +231,14 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[
export const SHOW_RELATED_INTEGRATIONS_SETTING =
'securitySolution:showRelatedIntegrations' as const;
/** This Kibana Advanced Setting enables extended rule execution logging to Event Log */
export const EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING =
'securitySolution:extendedRuleExecutionLoggingEnabled' as const;
/** This Kibana Advanced Setting sets minimum log level starting from which execution logs will be written to Event Log */
export const EXTENDED_RULE_EXECUTION_LOGGING_MIN_LEVEL_SETTING =
'securitySolution:extendedRuleExecutionLoggingMinLevel' as const;
/**
* Id for the notifications alerting type
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
@ -268,10 +276,6 @@ export const DETECTION_ENGINE_RULES_BULK_UPDATE =
* Internal detection engine routes
*/
export const INTERNAL_DETECTION_ENGINE_URL = '/internal/detection_engine' as const;
export const DETECTION_ENGINE_RULE_EXECUTION_EVENTS_URL =
`${INTERNAL_DETECTION_ENGINE_URL}/rules/{ruleId}/execution/events` as const;
export const detectionEngineRuleExecutionEventsUrl = (ruleId: string) =>
`${INTERNAL_DETECTION_ENGINE_URL}/rules/${ruleId}/execution/events` as const;
export const DETECTION_ENGINE_INSTALLED_INTEGRATIONS_URL =
`${INTERNAL_DETECTION_ENGINE_URL}/fleet/integrations/installed` as const;

View file

@ -0,0 +1,168 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import {
GetRuleExecutionEventsRequestParams,
GetRuleExecutionEventsRequestQuery,
} from './request_schema';
describe('Request schema of Get rule execution events', () => {
describe('GetRuleExecutionEventsRequestParams', () => {
describe('Validation succeeds', () => {
it('when required parameters are passed', () => {
const input = {
ruleId: 'some id',
};
const decoded = GetRuleExecutionEventsRequestParams.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(
expect.objectContaining({
ruleId: 'some id',
})
);
});
it('when unknown parameters are passed as well', () => {
const input = {
ruleId: 'some id',
foo: 'bar', // this one is not in the schema and will be stripped
};
const decoded = GetRuleExecutionEventsRequestParams.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({
ruleId: 'some id',
});
});
});
describe('Validation fails', () => {
const test = (input: unknown) => {
const decoded = GetRuleExecutionEventsRequestParams.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors)).length).toBeGreaterThan(0);
expect(message.schema).toEqual({});
};
it('when not all the required parameters are passed', () => {
const input = {};
test(input);
});
it('when ruleId is an empty string', () => {
const input: GetRuleExecutionEventsRequestParams = {
ruleId: '',
};
test(input);
});
});
});
describe('GetRuleExecutionEventsRequestQuery', () => {
describe('Validation succeeds', () => {
it('when valid parameters are passed', () => {
const input = {
event_types: 'message,status-change',
log_levels: 'debug,info,error',
sort_order: 'asc',
page: 42,
per_page: 6,
};
const decoded = GetRuleExecutionEventsRequestQuery.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({
event_types: ['message', 'status-change'],
log_levels: ['debug', 'info', 'error'],
sort_order: 'asc',
page: 42,
per_page: 6,
});
});
it('when unknown parameters are passed as well', () => {
const input = {
event_types: 'message,status-change',
log_levels: 'debug,info,error',
sort_order: 'asc',
page: 42,
per_page: 6,
foo: 'bar', // this one is not in the schema and will be stripped
};
const decoded = GetRuleExecutionEventsRequestQuery.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({
event_types: ['message', 'status-change'],
log_levels: ['debug', 'info', 'error'],
sort_order: 'asc',
page: 42,
per_page: 6,
});
});
it('when no parameters are passed (all are have default values)', () => {
const input = {};
const decoded = GetRuleExecutionEventsRequestQuery.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expect.any(Object));
});
});
describe('Validation fails', () => {
const test = (input: unknown) => {
const decoded = GetRuleExecutionEventsRequestQuery.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors)).length).toBeGreaterThan(0);
expect(message.schema).toEqual({});
};
it('when invalid parameters are passed', () => {
test({
event_types: 'foo,status-change',
});
});
});
describe('Validation sets default values', () => {
it('when optional parameters are not passed', () => {
const input = {};
const decoded = GetRuleExecutionEventsRequestQuery.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({
event_types: [],
log_levels: [],
sort_order: 'desc',
page: 1,
per_page: 20,
});
});
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 * as t from 'io-ts';
import { DefaultPerPage, DefaultPage } from '@kbn/securitysolution-io-ts-alerting-types';
import { defaultCsvArray, NonEmptyString } from '@kbn/securitysolution-io-ts-types';
import { DefaultSortOrderDesc } from '../../../schemas/common';
import { TRuleExecutionEventType } from '../../model/execution_event';
import { TLogLevel } from '../../model/log_level';
/**
* Path parameters of the API route.
*/
export type GetRuleExecutionEventsRequestParams = t.TypeOf<
typeof GetRuleExecutionEventsRequestParams
>;
export const GetRuleExecutionEventsRequestParams = t.exact(
t.type({
ruleId: NonEmptyString,
})
);
/**
* Query string parameters of the API route.
*/
export type GetRuleExecutionEventsRequestQuery = t.TypeOf<
typeof GetRuleExecutionEventsRequestQuery
>;
export const GetRuleExecutionEventsRequestQuery = t.exact(
t.type({
event_types: defaultCsvArray(TRuleExecutionEventType),
log_levels: defaultCsvArray(TLogLevel),
sort_order: DefaultSortOrderDesc, // defaults to 'desc'
page: DefaultPage, // defaults to 1
per_page: DefaultPerPage, // defaults to 20
})
);

View file

@ -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 { ruleExecutionEventMock } from '../../model/execution_event.mock';
import type { GetRuleExecutionEventsResponse } from './response_schema';
const getSomeResponse = (): GetRuleExecutionEventsResponse => {
const events = ruleExecutionEventMock.getSomeEvents();
return {
events,
pagination: {
page: 1,
per_page: events.length,
total: events.length * 10,
},
};
};
export const getRuleExecutionEventsResponseMock = {
getSomeResponse,
};

View file

@ -0,0 +1,21 @@
/*
* 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 * as t from 'io-ts';
import { PaginationResult } from '../../../schemas/common';
import { RuleExecutionEvent } from '../../model/execution_event';
/**
* Response body of the API route.
*/
export type GetRuleExecutionEventsResponse = t.TypeOf<typeof GetRuleExecutionEventsResponse>;
export const GetRuleExecutionEventsResponse = t.exact(
t.type({
events: t.array(RuleExecutionEvent),
pagination: PaginationResult,
})
);

View file

@ -0,0 +1,271 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { RULE_EXECUTION_STATUSES } from '../../model/execution_status';
import { DefaultSortField, DefaultRuleExecutionStatusCsvArray } from './request_schema';
describe('Request schema of Get rule execution results', () => {
describe('DefaultRuleExecutionStatusCsvArray', () => {
describe('Validation succeeds', () => {
describe('when input is a single rule execution status', () => {
const cases = RULE_EXECUTION_STATUSES.map((supportedStatus) => {
return { input: supportedStatus };
});
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = [input]; // note that it's an array after decode
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
describe('when input is an array of rule execution statuses', () => {
const cases = [
{ input: ['succeeded', 'failed'] },
{ input: ['partial failure', 'going to run', 'running'] },
];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput = input;
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
describe('when input is a string which is a comma-separated array of statuses', () => {
const cases = [
{
input: 'succeeded,failed',
expectedOutput: ['succeeded', 'failed'],
},
{
input: 'partial failure,going to run,running',
expectedOutput: ['partial failure', 'going to run', 'running'],
},
];
cases.forEach(({ input, expectedOutput }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
describe('Validation fails', () => {
describe('when input is a single invalid value', () => {
const cases = [
{
input: 'val',
expectedErrors: [
'Invalid value "val" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: '5',
expectedErrors: [
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: 5,
expectedErrors: [
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: {},
expectedErrors: [
'Invalid value "{}" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
describe('when input is an array of invalid values', () => {
const cases = [
{
input: ['value 1', 5],
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: ['value 1', 'succeeded'],
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: ['', 5, {}],
expectedErrors: [
'Invalid value "" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "{}" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
describe('when input is a string which is a comma-separated array of invalid values', () => {
const cases = [
{
input: 'value 1,5',
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: 'value 1,succeeded',
expectedErrors: [
'Invalid value "value 1" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
{
input: ',5,{}',
expectedErrors: [
'Invalid value "" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "5" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
'Invalid value "{}" supplied to "DefaultCsvArray<RuleExecutionStatus>"',
],
},
];
cases.forEach(({ input, expectedErrors }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
});
describe('Validation returns default value (an empty array)', () => {
describe('when input is', () => {
const cases = [{ input: null }, { input: undefined }, { input: '' }, { input: [] }];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultRuleExecutionStatusCsvArray.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedOutput: string[] = [];
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expectedOutput);
});
});
});
});
});
describe('DefaultSortField', () => {
describe('Validation succeeds', () => {
describe('when input is a valid sort field', () => {
const cases = [
{ input: 'timestamp' },
{ input: 'duration_ms' },
{ input: 'gap_duration_s' },
{ input: 'indexing_duration_ms' },
{ input: 'search_duration_ms' },
{ input: 'schedule_delay_ms' },
];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultSortField.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(input);
});
});
});
});
describe('Validation fails', () => {
describe('when input is an invalid sort field', () => {
const cases = [
{ input: 'status' },
{ input: 'message' },
{ input: 'es_search_duration_ms' },
{ input: 'security_status' },
{ input: 'security_message' },
];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultSortField.decode(input);
const message = pipe(decoded, foldLeftRight);
const expectedErrors = [`Invalid value "${input}" supplied to "DefaultSortField"`];
expect(getPaths(left(message.errors))).toEqual(expectedErrors);
expect(message.schema).toEqual({});
});
});
});
});
describe('Validation returns the default sort field "timestamp"', () => {
describe('when input is', () => {
const cases = [{ input: null }, { input: undefined }];
cases.forEach(({ input }) => {
it(`${input}`, () => {
const decoded = DefaultSortField.decode(input);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('timestamp');
});
});
});
});
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 * as t from 'io-ts';
import { DefaultPage, DefaultPerPage } from '@kbn/securitysolution-io-ts-alerting-types';
import {
defaultCsvArray,
DefaultEmptyString,
defaultValue,
IsoDateString,
NonEmptyString,
} from '@kbn/securitysolution-io-ts-types';
import { DefaultSortOrderDesc } from '../../../schemas/common';
import { SortFieldOfRuleExecutionResult } from '../../model/execution_result';
import { TRuleExecutionStatus } from '../../model/execution_status';
/**
* Types the DefaultRuleExecutionStatusCsvArray as:
* - If not specified, then a default empty array will be set
* - If an array is sent in, then the array will be validated to ensure all elements are a RuleExecutionStatus
* (or that the array is empty)
* - If a CSV string is sent in, then it will be parsed to an array which will be validated
*/
export const DefaultRuleExecutionStatusCsvArray = defaultCsvArray(TRuleExecutionStatus);
/**
* Types the DefaultSortField as:
* - If undefined, then a default sort field of 'timestamp' will be set
* - If a string is sent in, then the string will be validated to ensure it is as valid sortFields
*/
export const DefaultSortField = defaultValue(
SortFieldOfRuleExecutionResult,
'timestamp',
'DefaultSortField'
);
/**
* Path parameters of the API route.
*/
export type GetRuleExecutionResultsRequestParams = t.TypeOf<
typeof GetRuleExecutionResultsRequestParams
>;
export const GetRuleExecutionResultsRequestParams = t.exact(
t.type({
ruleId: NonEmptyString,
})
);
/**
* Query string parameters of the API route.
*/
export type GetRuleExecutionResultsRequestQuery = t.TypeOf<
typeof GetRuleExecutionResultsRequestQuery
>;
export const GetRuleExecutionResultsRequestQuery = t.exact(
t.type({
start: IsoDateString,
end: IsoDateString,
query_text: DefaultEmptyString, // defaults to ''
status_filters: DefaultRuleExecutionStatusCsvArray, // defaults to []
sort_field: DefaultSortField, // defaults to 'timestamp'
sort_order: DefaultSortOrderDesc, // defaults to 'desc'
page: DefaultPage, // defaults to 1
per_page: DefaultPerPage, // defaults to 20
})
);

View file

@ -0,0 +1,21 @@
/*
* 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 { ruleExecutionResultMock } from '../../model/execution_result.mock';
import type { GetRuleExecutionResultsResponse } from './response_schema';
const getSomeResponse = (): GetRuleExecutionResultsResponse => {
const results = ruleExecutionResultMock.getSomeResults();
return {
events: results,
total: results.length,
};
};
export const getRuleExecutionResultsResponseMock = {
getSomeResponse,
};

View file

@ -6,15 +6,15 @@
*/
import * as t from 'io-ts';
import { aggregateRuleExecutionEvent } from '../common';
import { RuleExecutionResult } from '../../model/execution_result';
export const GetAggregateRuleExecutionEventsResponse = t.exact(
/**
* Response body of the API route.
*/
export type GetRuleExecutionResultsResponse = t.TypeOf<typeof GetRuleExecutionResultsResponse>;
export const GetRuleExecutionResultsResponse = t.exact(
t.type({
events: t.array(aggregateRuleExecutionEvent),
events: t.array(RuleExecutionResult),
total: t.number,
})
);
export type GetAggregateRuleExecutionEventsResponse = t.TypeOf<
typeof GetAggregateRuleExecutionEventsResponse
>;

View file

@ -0,0 +1,18 @@
/*
* 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 { INTERNAL_DETECTION_ENGINE_URL as INTERNAL_URL } from '../../../constants';
export const GET_RULE_EXECUTION_EVENTS_URL =
`${INTERNAL_URL}/rules/{ruleId}/execution/events` as const;
export const getRuleExecutionEventsUrl = (ruleId: string) =>
`${INTERNAL_URL}/rules/${ruleId}/execution/events` as const;
export const GET_RULE_EXECUTION_RESULTS_URL =
`${INTERNAL_URL}/rules/{ruleId}/execution/results` as const;
export const getRuleExecutionResultsUrl = (ruleId: string) =>
`${INTERNAL_URL}/rules/${ruleId}/execution/results` as const;

View file

@ -0,0 +1,20 @@
/*
* 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 * from './api/get_rule_execution_events/request_schema';
export * from './api/get_rule_execution_events/response_schema';
export * from './api/get_rule_execution_results/request_schema';
export * from './api/get_rule_execution_results/response_schema';
export * from './api/urls';
export * from './model/execution_event';
export * from './model/execution_metrics';
export * from './model/execution_result';
export * from './model/execution_settings';
export * from './model/execution_status';
export * from './model/execution_summary';
export * from './model/log_level';

View file

@ -0,0 +1,13 @@
/*
* 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 * from './api/get_rule_execution_events/response_schema.mock';
export * from './api/get_rule_execution_results/response_schema.mock';
export * from './model/execution_event.mock';
export * from './model/execution_result.mock';
export * from './model/execution_summary.mock';

View file

@ -0,0 +1,152 @@
/*
* 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 { RuleExecutionEvent } from './execution_event';
import { RuleExecutionEventType } from './execution_event';
import { LogLevel } from './log_level';
const DEFAULT_TIMESTAMP = '2021-12-28T10:10:00.806Z';
const DEFAULT_SEQUENCE_NUMBER = 0;
const getMessageEvent = (props: Partial<RuleExecutionEvent> = {}): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
level: LogLevel.debug,
message: 'Some message',
// Overriden values
...props,
// Mandatory values for this type of event
type: RuleExecutionEventType.message,
};
};
const getRunningStatusChange = (props: Partial<RuleExecutionEvent> = {}): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
message: 'Rule changed status to "running"',
// Overriden values
...props,
// Mandatory values for this type of event
level: LogLevel.info,
type: RuleExecutionEventType['status-change'],
};
};
const getPartialFailureStatusChange = (
props: Partial<RuleExecutionEvent> = {}
): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
message: 'Rule changed status to "partial failure". Unknown error',
// Overriden values
...props,
// Mandatory values for this type of event
level: LogLevel.warn,
type: RuleExecutionEventType['status-change'],
};
};
const getFailedStatusChange = (props: Partial<RuleExecutionEvent> = {}): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
message: 'Rule changed status to "failed". Unknown error',
// Overriden values
...props,
// Mandatory values for this type of event
level: LogLevel.error,
type: RuleExecutionEventType['status-change'],
};
};
const getSucceededStatusChange = (props: Partial<RuleExecutionEvent> = {}): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
message: 'Rule changed status to "succeeded". Rule executed successfully',
// Overriden values
...props,
// Mandatory values for this type of event
level: LogLevel.info,
type: RuleExecutionEventType['status-change'],
};
};
const getExecutionMetricsEvent = (props: Partial<RuleExecutionEvent> = {}): RuleExecutionEvent => {
return {
// Default values
timestamp: DEFAULT_TIMESTAMP,
sequence: DEFAULT_SEQUENCE_NUMBER,
message: '',
// Overriden values
...props,
// Mandatory values for this type of event
level: LogLevel.debug,
type: RuleExecutionEventType['execution-metrics'],
};
};
const getSomeEvents = (): RuleExecutionEvent[] => [
getSucceededStatusChange({
timestamp: '2021-12-28T10:10:09.806Z',
sequence: 9,
}),
getExecutionMetricsEvent({
timestamp: '2021-12-28T10:10:08.806Z',
sequence: 8,
}),
getRunningStatusChange({
timestamp: '2021-12-28T10:10:07.806Z',
sequence: 7,
}),
getMessageEvent({
timestamp: '2021-12-28T10:10:06.806Z',
sequence: 6,
level: LogLevel.debug,
message: 'Rule execution started',
}),
getFailedStatusChange({
timestamp: '2021-12-28T10:10:05.806Z',
sequence: 5,
}),
getExecutionMetricsEvent({
timestamp: '2021-12-28T10:10:04.806Z',
sequence: 4,
}),
getPartialFailureStatusChange({
timestamp: '2021-12-28T10:10:03.806Z',
sequence: 3,
}),
getMessageEvent({
timestamp: '2021-12-28T10:10:02.806Z',
sequence: 2,
level: LogLevel.error,
message: 'Some error',
}),
getRunningStatusChange({
timestamp: '2021-12-28T10:10:01.806Z',
sequence: 1,
}),
getMessageEvent({
timestamp: '2021-12-28T10:10:00.806Z',
sequence: 0,
level: LogLevel.debug,
message: 'Rule execution started',
}),
];
export const ruleExecutionEventMock = {
getSomeEvents,
};

View file

@ -0,0 +1,60 @@
/*
* 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 * as t from 'io-ts';
import { enumeration, IsoDateString } from '@kbn/securitysolution-io-ts-types';
import { enumFromString } from '../../../utils/enum_from_string';
import { TLogLevel } from './log_level';
/**
* Type of a plain rule execution event.
*/
export enum RuleExecutionEventType {
/**
* Simple log message of some log level, such as debug, info or error.
*/
'message' = 'message',
/**
* We log an event of this type each time a rule changes its status during an execution.
*/
'status-change' = 'status-change',
/**
* We log an event of this type at the end of a rule execution. It contains various execution
* metrics such as search and indexing durations.
*/
'execution-metrics' = 'execution-metrics',
}
export const TRuleExecutionEventType = enumeration(
'RuleExecutionEventType',
RuleExecutionEventType
);
/**
* An array of supported types of rule execution events.
*/
export const RULE_EXECUTION_EVENT_TYPES = Object.values(RuleExecutionEventType);
export const ruleExecutionEventTypeFromString = enumFromString(RuleExecutionEventType);
/**
* Plain rule execution event. A rule can write many of them during each execution. Events can be
* of different types and log levels.
*
* NOTE: This is a read model of rule execution events and it is pretty generic. It contains only a
* subset of their fields: only those fields that are common to all types of execution events.
*/
export type RuleExecutionEvent = t.TypeOf<typeof RuleExecutionEvent>;
export const RuleExecutionEvent = t.type({
timestamp: IsoDateString,
sequence: t.number,
level: TLogLevel,
type: TRuleExecutionEventType,
message: t.string,
});

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.
*/
import * as t from 'io-ts';
import { PositiveInteger } from '@kbn/securitysolution-io-ts-types';
export type DurationMetric = t.TypeOf<typeof DurationMetric>;
export const DurationMetric = PositiveInteger;
export type RuleExecutionMetrics = t.TypeOf<typeof RuleExecutionMetrics>;
export const RuleExecutionMetrics = t.partial({
total_search_duration_ms: DurationMetric,
total_indexing_duration_ms: DurationMetric,
execution_gap_duration_s: DurationMetric,
});

View file

@ -0,0 +1,176 @@
/*
* 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 { RuleExecutionResult } from './execution_result';
const getSomeResults = (): RuleExecutionResult[] => [
{
execution_uuid: 'dc45a63c-4872-4964-a2d0-bddd8b2e634d',
timestamp: '2022-04-28T21:19:08.047Z',
duration_ms: 3,
status: 'failure',
message: 'siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: execution failed',
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2169,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 0,
gap_duration_s: 0,
security_status: 'failed',
security_message: 'Rule failed to execute because rule ran after it was disabled.',
},
{
execution_uuid: '0fde9271-05d0-4bfb-8ff8-815756d28350',
timestamp: '2022-04-28T21:19:04.973Z',
duration_ms: 1446,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2089,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 2,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '5daaa259-ded8-4a52-853e-1e7652d325d5',
timestamp: '2022-04-28T21:19:01.976Z',
duration_ms: 1395,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 1,
schedule_delay_ms: 2637,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 3,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: 'c7223e1c-4264-4a27-8697-0d720243fafc',
timestamp: '2022-04-28T21:18:58.431Z',
duration_ms: 1815,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 1,
schedule_delay_ms: -255429,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 3,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '1f6ba0c1-cc36-4f45-b919-7790b8a8d670',
timestamp: '2022-04-28T21:18:13.954Z',
duration_ms: 2055,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2027,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 0,
gap_duration_s: 0,
security_status: 'partial failure',
security_message:
'Check privileges failed to execute ResponseError: index_not_found_exception: [index_not_found_exception] Reason: no such index [yup] name: "Click here for hot fresh alerts!" id: "a6e61cf0-c737-11ec-9e32-e14913ffdd2d" rule id: "34946b12-88d1-49ef-82b7-9cad45972030" execution id: "1f6ba0c1-cc36-4f45-b919-7790b8a8d670" space ID: "default"',
},
{
execution_uuid: 'b0f65d64-b229-432b-9d39-f4385a7f9368',
timestamp: '2022-04-28T21:15:43.086Z',
duration_ms: 1205,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 672,
schedule_delay_ms: 3086,
timed_out: false,
indexing_duration_ms: 140,
search_duration_ms: 684,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '7bfd25b9-c0d8-44b1-982c-485169466a8e',
timestamp: '2022-04-28T21:10:40.135Z',
duration_ms: 6321,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 930,
schedule_delay_ms: 1222,
timed_out: false,
indexing_duration_ms: 2103,
search_duration_ms: 946,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
];
export const ruleExecutionResultMock = {
getSomeResults,
};

View file

@ -0,0 +1,50 @@
/*
* 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 * as t from 'io-ts';
import { IsoDateString } from '@kbn/securitysolution-io-ts-types';
/**
* Rule execution result is an aggregate that groups plain rule execution events by execution UUID.
* It contains such information as execution UUID, date, status and metrics.
*/
export type RuleExecutionResult = t.TypeOf<typeof RuleExecutionResult>;
export const RuleExecutionResult = t.type({
execution_uuid: t.string,
timestamp: IsoDateString,
duration_ms: t.number,
status: t.string,
message: t.string,
num_active_alerts: t.number,
num_new_alerts: t.number,
num_recovered_alerts: t.number,
num_triggered_actions: t.number,
num_succeeded_actions: t.number,
num_errored_actions: t.number,
total_search_duration_ms: t.number,
es_search_duration_ms: t.number,
schedule_delay_ms: t.number,
timed_out: t.boolean,
indexing_duration_ms: t.number,
search_duration_ms: t.number,
gap_duration_s: t.number,
security_status: t.string,
security_message: t.string,
});
/**
* We support sorting rule execution results by these fields.
*/
export type SortFieldOfRuleExecutionResult = t.TypeOf<typeof SortFieldOfRuleExecutionResult>;
export const SortFieldOfRuleExecutionResult = t.keyof({
timestamp: IsoDateString,
duration_ms: t.number,
gap_duration_s: t.number,
indexing_duration_ms: t.number,
search_duration_ms: t.number,
schedule_delay_ms: t.number,
});

View file

@ -0,0 +1,22 @@
/*
* 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 interface RuleExecutionSettings {
extendedLogging: {
isEnabled: boolean;
minLevel: LogLevelSetting;
};
}
export enum LogLevelSetting {
'trace' = 'trace',
'debug' = 'debug',
'info' = 'info',
'warn' = 'warn',
'error' = 'error',
'off' = 'off',
}

View file

@ -0,0 +1,80 @@
/*
* 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 * as t from 'io-ts';
import { enumeration, PositiveInteger } from '@kbn/securitysolution-io-ts-types';
import { assertUnreachable } from '../../../utility_types';
/**
* Custom execution status of Security rules that is different from the status
* used in the Alerting Framework. We merge our custom status with the
* Framework's status to determine the resulting status of a rule.
*/
export enum RuleExecutionStatus {
/**
* @deprecated Replaced by the 'running' status but left for backwards compatibility
* with rule execution events already written to Event Log in the prior versions of Kibana.
* Don't use when writing rule status changes.
*/
'going to run' = 'going to run',
/**
* Rule execution started but not reached any intermediate or final status.
*/
'running' = 'running',
/**
* Rule can partially fail for various reasons either in the middle of an execution
* (in this case we update its status right away) or in the end of it. So currently
* this status can be both intermediate and final at the same time.
* A typical reason for a partial failure: not all the indices that the rule searches
* over actually exist.
*/
'partial failure' = 'partial failure',
/**
* Rule failed to execute due to unhandled exception or a reason defined in the
* business logic of its executor function.
*/
'failed' = 'failed',
/**
* Rule executed successfully without any issues. Note: this status is just an indication
* of a rule's "health". The rule might or might not generate any alerts despite of it.
*/
'succeeded' = 'succeeded',
}
export const TRuleExecutionStatus = enumeration('RuleExecutionStatus', RuleExecutionStatus);
/**
* An array of supported rule execution statuses.
*/
export const RULE_EXECUTION_STATUSES = Object.values(RuleExecutionStatus);
export type RuleExecutionStatusOrder = t.TypeOf<typeof RuleExecutionStatusOrder>;
export const RuleExecutionStatusOrder = PositiveInteger;
export const ruleExecutionStatusToNumber = (
status: RuleExecutionStatus
): RuleExecutionStatusOrder => {
switch (status) {
case RuleExecutionStatus.succeeded:
return 0;
case RuleExecutionStatus['going to run']:
return 10;
case RuleExecutionStatus.running:
return 15;
case RuleExecutionStatus['partial failure']:
return 20;
case RuleExecutionStatus.failed:
return 30;
default:
assertUnreachable(status);
return 0;
}
};

View file

@ -0,0 +1,43 @@
/*
* 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 { RuleExecutionStatus } from './execution_status';
import type { RuleExecutionSummary } from './execution_summary';
const getSummarySucceeded = (): RuleExecutionSummary => ({
last_execution: {
date: '2020-02-18T15:26:49.783Z',
status: RuleExecutionStatus.succeeded,
status_order: 0,
message: 'succeeded',
metrics: {
total_search_duration_ms: 200,
total_indexing_duration_ms: 800,
execution_gap_duration_s: 500,
},
},
});
const getSummaryFailed = (): RuleExecutionSummary => ({
last_execution: {
date: '2020-02-18T15:15:58.806Z',
status: RuleExecutionStatus.failed,
status_order: 30,
message:
'Signal rule name: "Query with a rule id Number 1", id: "1ea5a820-4da1-4e82-92a1-2b43a7bece08", rule_id: "query-rule-id-1" has a time gap of 5 days (412682928ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.',
metrics: {
total_search_duration_ms: 200,
total_indexing_duration_ms: 800,
execution_gap_duration_s: 500,
},
},
});
export const ruleExecutionSummaryMock = {
getSummarySucceeded,
getSummaryFailed,
};

View file

@ -0,0 +1,22 @@
/*
* 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 * as t from 'io-ts';
import { IsoDateString } from '@kbn/securitysolution-io-ts-types';
import { TRuleExecutionStatus, RuleExecutionStatusOrder } from './execution_status';
import { RuleExecutionMetrics } from './execution_metrics';
export type RuleExecutionSummary = t.TypeOf<typeof RuleExecutionSummary>;
export const RuleExecutionSummary = t.type({
last_execution: t.type({
date: IsoDateString,
status: TRuleExecutionStatus,
status_order: RuleExecutionStatusOrder,
message: t.string,
metrics: RuleExecutionMetrics,
}),
});

View file

@ -0,0 +1,82 @@
/*
* 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 { enumeration } from '@kbn/securitysolution-io-ts-types';
import { enumFromString } from '../../../utils/enum_from_string';
import { assertUnreachable } from '../../../utility_types';
import { RuleExecutionStatus } from './execution_status';
export enum LogLevel {
'trace' = 'trace',
'debug' = 'debug',
'info' = 'info',
'warn' = 'warn',
'error' = 'error',
}
export const TLogLevel = enumeration('LogLevel', LogLevel);
/**
* An array of supported log levels.
*/
export const LOG_LEVELS = Object.values(LogLevel);
export const logLevelToNumber = (level: keyof typeof LogLevel | null | undefined): number => {
if (!level) {
return 0;
}
switch (level) {
case 'trace':
return 0;
case 'debug':
return 10;
case 'info':
return 20;
case 'warn':
return 30;
case 'error':
return 40;
default:
assertUnreachable(level);
return 0;
}
};
export const logLevelFromNumber = (num: number | null | undefined): LogLevel => {
if (num === null || num === undefined || num < 10) {
return LogLevel.trace;
}
if (num < 20) {
return LogLevel.debug;
}
if (num < 30) {
return LogLevel.info;
}
if (num < 40) {
return LogLevel.warn;
}
return LogLevel.error;
};
export const logLevelFromString = enumFromString(LogLevel);
export const logLevelFromExecutionStatus = (status: RuleExecutionStatus): LogLevel => {
switch (status) {
case RuleExecutionStatus['going to run']:
case RuleExecutionStatus.running:
case RuleExecutionStatus.succeeded:
return LogLevel.info;
case RuleExecutionStatus['partial failure']:
return LogLevel.warn;
case RuleExecutionStatus.failed:
return LogLevel.error;
default:
assertUnreachable(status);
return LogLevel.trace;
}
};

View file

@ -6,6 +6,7 @@
*/
export * from './installed_integrations';
export * from './rule_monitoring';
export * from './pagination';
export * from './rule_params';
export * from './schemas';
export * from './sorting';

View file

@ -0,0 +1,28 @@
/*
* 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 * as t from 'io-ts';
import { PositiveInteger, PositiveIntegerGreaterThanZero } from '@kbn/securitysolution-io-ts-types';
export type Page = t.TypeOf<typeof Page>;
export const Page = PositiveIntegerGreaterThanZero;
export type PageOrUndefined = t.TypeOf<typeof PageOrUndefined>;
export const PageOrUndefined = t.union([Page, t.undefined]);
export type PerPage = t.TypeOf<typeof PerPage>;
export const PerPage = PositiveInteger;
export type PerPageOrUndefined = t.TypeOf<typeof PerPageOrUndefined>;
export const PerPageOrUndefined = t.union([PerPage, t.undefined]);
export type PaginationResult = t.TypeOf<typeof PaginationResult>;
export const PaginationResult = t.type({
page: Page,
per_page: PerPage,
total: PositiveInteger,
});

View file

@ -1,147 +0,0 @@
/*
* 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 * as t from 'io-ts';
import { enumeration, IsoDateString, PositiveInteger } from '@kbn/securitysolution-io-ts-types';
// -------------------------------------------------------------------------------------------------
// Rule execution status
/**
* Custom execution status of Security rules that is different from the status
* used in the Alerting Framework. We merge our custom status with the
* Framework's status to determine the resulting status of a rule.
*/
export enum RuleExecutionStatus {
/**
* @deprecated Replaced by the 'running' status but left for backwards compatibility
* with rule execution events already written to Event Log in the prior versions of Kibana.
* Don't use when writing rule status changes.
*/
'going to run' = 'going to run',
/**
* Rule execution started but not reached any intermediate or final status.
*/
'running' = 'running',
/**
* Rule can partially fail for various reasons either in the middle of an execution
* (in this case we update its status right away) or in the end of it. So currently
* this status can be both intermediate and final at the same time.
* A typical reason for a partial failure: not all the indices that the rule searches
* over actually exist.
*/
'partial failure' = 'partial failure',
/**
* Rule failed to execute due to unhandled exception or a reason defined in the
* business logic of its executor function.
*/
'failed' = 'failed',
/**
* Rule executed successfully without any issues. Note: this status is just an indication
* of a rule's "health". The rule might or might not generate any alerts despite of it.
*/
'succeeded' = 'succeeded',
}
export const ruleExecutionStatus = enumeration('RuleExecutionStatus', RuleExecutionStatus);
export const ruleExecutionStatusOrder = PositiveInteger;
export type RuleExecutionStatusOrder = t.TypeOf<typeof ruleExecutionStatusOrder>;
export const ruleExecutionStatusOrderByStatus: Record<
RuleExecutionStatus,
RuleExecutionStatusOrder
> = {
[RuleExecutionStatus.succeeded]: 0,
[RuleExecutionStatus['going to run']]: 10,
[RuleExecutionStatus.running]: 15,
[RuleExecutionStatus['partial failure']]: 20,
[RuleExecutionStatus.failed]: 30,
};
// -------------------------------------------------------------------------------------------------
// Rule execution metrics
export const durationMetric = PositiveInteger;
export type DurationMetric = t.TypeOf<typeof durationMetric>;
export const ruleExecutionMetrics = t.partial({
total_search_duration_ms: durationMetric,
total_indexing_duration_ms: durationMetric,
execution_gap_duration_s: durationMetric,
});
export type RuleExecutionMetrics = t.TypeOf<typeof ruleExecutionMetrics>;
// -------------------------------------------------------------------------------------------------
// Rule execution summary
export const ruleExecutionSummary = t.type({
last_execution: t.type({
date: IsoDateString,
status: ruleExecutionStatus,
status_order: ruleExecutionStatusOrder,
message: t.string,
metrics: ruleExecutionMetrics,
}),
});
export type RuleExecutionSummary = t.TypeOf<typeof ruleExecutionSummary>;
// -------------------------------------------------------------------------------------------------
// Rule execution events
export const ruleExecutionEvent = t.type({
date: IsoDateString,
status: ruleExecutionStatus,
message: t.string,
});
export type RuleExecutionEvent = t.TypeOf<typeof ruleExecutionEvent>;
// -------------------------------------------------------------------------------------------------
// Aggregate Rule execution events
export const aggregateRuleExecutionEvent = t.type({
execution_uuid: t.string,
timestamp: IsoDateString,
duration_ms: t.number,
status: t.string,
message: t.string,
num_active_alerts: t.number,
num_new_alerts: t.number,
num_recovered_alerts: t.number,
num_triggered_actions: t.number,
num_succeeded_actions: t.number,
num_errored_actions: t.number,
total_search_duration_ms: t.number,
es_search_duration_ms: t.number,
schedule_delay_ms: t.number,
timed_out: t.boolean,
indexing_duration_ms: t.number,
search_duration_ms: t.number,
gap_duration_s: t.number,
security_status: t.string,
security_message: t.string,
});
export type AggregateRuleExecutionEvent = t.TypeOf<typeof aggregateRuleExecutionEvent>;
export const executionLogTableSortColumns = t.keyof({
timestamp: IsoDateString,
duration_ms: t.number,
gap_duration_s: t.number,
indexing_duration_ms: t.number,
search_duration_ms: t.number,
schedule_delay_ms: t.number,
});
export type ExecutionLogTableSortColumns = t.TypeOf<typeof executionLogTableSortColumns>;

View file

@ -193,36 +193,12 @@ export type QueryFilterOrUndefined = t.TypeOf<typeof queryFilterOrUndefined>;
export const references = t.array(t.string);
export type References = t.TypeOf<typeof references>;
export const per_page = PositiveInteger;
export type PerPage = t.TypeOf<typeof per_page>;
export const perPageOrUndefined = t.union([per_page, t.undefined]);
export type PerPageOrUndefined = t.TypeOf<typeof perPageOrUndefined>;
export const page = PositiveIntegerGreaterThanZero;
export type Page = t.TypeOf<typeof page>;
export const pageOrUndefined = t.union([page, t.undefined]);
export type PageOrUndefined = t.TypeOf<typeof pageOrUndefined>;
export const signal_ids = t.array(t.string);
export type SignalIds = t.TypeOf<typeof signal_ids>;
// TODO: Can this be more strict or is this is the set of all Elastic Queries?
export const signal_status_query = t.object;
export const sort_field = t.string;
export type SortField = t.TypeOf<typeof sort_field>;
export const sortFieldOrUndefined = t.union([sort_field, t.undefined]);
export type SortFieldOrUndefined = t.TypeOf<typeof sortFieldOrUndefined>;
export const sort_order = t.keyof({ asc: null, desc: null });
export type SortOrder = t.TypeOf<typeof sort_order>;
export const sortOrderOrUndefined = t.union([sort_order, t.undefined]);
export type SortOrderOrUndefined = t.TypeOf<typeof sortOrderOrUndefined>;
export const tags = t.array(t.string);
export type Tags = t.TypeOf<typeof tags>;

View file

@ -0,0 +1,87 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { DefaultSortOrderAsc, DefaultSortOrderDesc } from './sorting';
describe('Common sorting schemas', () => {
describe('DefaultSortOrderAsc', () => {
describe('Validation succeeds', () => {
it('when valid sort order is passed', () => {
const payload = 'desc';
const decoded = DefaultSortOrderAsc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
});
describe('Validation fails', () => {
it('when invalid sort order is passed', () => {
const payload = 'behind_you';
const decoded = DefaultSortOrderAsc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "behind_you" supplied to "DefaultSortOrderAsc"',
]);
expect(message.schema).toEqual({});
});
});
describe('Validation sets the default sort order "asc"', () => {
it('when sort order is not passed', () => {
const payload = undefined;
const decoded = DefaultSortOrderAsc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('asc');
});
});
});
describe('DefaultSortOrderDesc', () => {
describe('Validation succeeds', () => {
it('when valid sort order is passed', () => {
const payload = 'asc';
const decoded = DefaultSortOrderDesc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
});
describe('Validation fails', () => {
it('when invalid sort order is passed', () => {
const payload = 'behind_you';
const decoded = DefaultSortOrderDesc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "behind_you" supplied to "DefaultSortOrderDesc"',
]);
expect(message.schema).toEqual({});
});
});
describe('Validation sets the default sort order "desc"', () => {
it('when sort order is not passed', () => {
const payload = null;
const decoded = DefaultSortOrderDesc.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('desc');
});
});
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 * as t from 'io-ts';
import type { Either } from 'fp-ts/lib/Either';
import { capitalize } from 'lodash';
export type SortField = t.TypeOf<typeof SortField>;
export const SortField = t.string;
export type SortFieldOrUndefined = t.TypeOf<typeof SortFieldOrUndefined>;
export const SortFieldOrUndefined = t.union([SortField, t.undefined]);
export type SortOrder = t.TypeOf<typeof SortOrder>;
export const SortOrder = t.keyof({ asc: null, desc: null });
export type SortOrderOrUndefined = t.TypeOf<typeof SortOrderOrUndefined>;
export const SortOrderOrUndefined = t.union([SortOrder, t.undefined]);
const defaultSortOrder = (order: SortOrder): t.Type<SortOrder, SortOrder, unknown> => {
return new t.Type<SortOrder, SortOrder, unknown>(
`DefaultSortOrder${capitalize(order)}`,
SortOrder.is,
(input, context): Either<t.Errors, SortOrder> =>
input == null ? t.success(order) : SortOrder.validate(input, context),
t.identity
);
};
/**
* Types the DefaultSortOrderAsc as:
* - If undefined, then a default sort order of 'asc' will be set
* - If a string is sent in, then the string will be validated to ensure it's a valid SortOrder
*/
export const DefaultSortOrderAsc = defaultSortOrder('asc');
/**
* Types the DefaultSortOrderDesc as:
* - If undefined, then a default sort order of 'desc' will be set
* - If a string is sent in, then the string will be validated to ensure it's a valid SortOrder
*/
export const DefaultSortOrderDesc = defaultSortOrder('desc');

View file

@ -8,22 +8,22 @@
import * as t from 'io-ts';
import { DefaultPerPage, DefaultPage } from '@kbn/securitysolution-io-ts-alerting-types';
import type { PerPage, Page } from '../common/schemas';
import { queryFilter, fields, sort_field, sort_order } from '../common/schemas';
import type { PerPage, Page } from '../common';
import { queryFilter, fields, SortField, SortOrder } from '../common';
export const findRulesSchema = t.exact(
t.partial({
fields,
filter: queryFilter,
per_page: DefaultPerPage, // defaults to "20" if not sent in during decode
page: DefaultPage, // defaults to "1" if not sent in during decode
sort_field,
sort_order,
sort_field: SortField,
sort_order: SortOrder,
page: DefaultPage, // defaults to 1
per_page: DefaultPerPage, // defaults to 20
})
);
export type FindRulesSchema = t.TypeOf<typeof findRulesSchema>;
export type FindRulesSchemaDecoded = Omit<FindRulesSchema, 'per_page'> & {
per_page: PerPage;
page: Page;
per_page: PerPage;
};

View file

@ -1,117 +0,0 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import {
DefaultSortField,
DefaultSortOrder,
DefaultStatusFiltersStringArray,
} from './get_rule_execution_events_schema';
describe('get_rule_execution_events_schema', () => {
describe('DefaultStatusFiltersStringArray', () => {
test('it should validate a single ruleExecutionStatus', () => {
const payload = 'succeeded';
const decoded = DefaultStatusFiltersStringArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual([payload]);
});
test('it should validate an array of ruleExecutionStatus joined by "\'"', () => {
const payload = ['succeeded', 'failed'];
const decoded = DefaultStatusFiltersStringArray.decode(payload.join(','));
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate an invalid ruleExecutionStatus', () => {
const payload = ['value 1', 5].join(',');
const decoded = DefaultStatusFiltersStringArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "value 1" supplied to "DefaultStatusFiltersStringArray"',
'Invalid value "5" supplied to "DefaultStatusFiltersStringArray"',
]);
expect(message.schema).toEqual({});
});
test('it should return a default array entry', () => {
const payload = null;
const decoded = DefaultStatusFiltersStringArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual([]);
});
});
describe('DefaultSortField', () => {
test('it should validate a valid sort field', () => {
const payload = 'duration_ms';
const decoded = DefaultSortField.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate an invalid sort field', () => {
const payload = 'es_search_duration_ms';
const decoded = DefaultSortField.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "es_search_duration_ms" supplied to "DefaultSortField"',
]);
expect(message.schema).toEqual({});
});
test('it should return the default sort field "timestamp"', () => {
const payload = null;
const decoded = DefaultSortField.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('timestamp');
});
});
describe('DefaultSortOrder', () => {
test('it should validate a valid sort order', () => {
const payload = 'asc';
const decoded = DefaultSortOrder.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate an invalid sort order', () => {
const payload = 'behind_you';
const decoded = DefaultSortOrder.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "behind_you" supplied to "DefaultSortOrder"',
]);
expect(message.schema).toEqual({});
});
test('it should return the default sort order "desc"', () => {
const payload = null;
const decoded = DefaultSortOrder.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('desc');
});
});
});

View file

@ -1,105 +0,0 @@
/*
* 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 * as t from 'io-ts';
import { DefaultPerPage, DefaultPage } from '@kbn/securitysolution-io-ts-alerting-types';
import { DefaultEmptyString, IsoDateString } from '@kbn/securitysolution-io-ts-types';
import type { Either } from 'fp-ts/lib/Either';
import type { ExecutionLogTableSortColumns, RuleExecutionStatus } from '../common';
import { executionLogTableSortColumns, ruleExecutionStatus } from '../common';
/**
* Types the DefaultStatusFiltersStringArray as:
* - If undefined, then a default array will be set
* - If an array is sent in, then the array will be validated to ensure all elements are a ruleExecutionStatus (or that the array is empty)
*/
export const DefaultStatusFiltersStringArray = new t.Type<
RuleExecutionStatus[],
RuleExecutionStatus[],
unknown
>(
'DefaultStatusFiltersStringArray',
t.array(ruleExecutionStatus).is,
(input, context): Either<t.Errors, RuleExecutionStatus[]> => {
if (input == null) {
return t.success([]);
} else if (typeof input === 'string') {
if (input === '') {
return t.success([]);
} else {
return t.array(ruleExecutionStatus).validate(input.split(','), context);
}
} else {
return t.array(ruleExecutionStatus).validate(input, context);
}
},
t.identity
);
/**
* Types the DefaultSortField as:
* - If undefined, then a default sort field of 'timestamp' will be set
* - If a string is sent in, then the string will be validated to ensure it is as valid sortFields
*/
export const DefaultSortField = new t.Type<
ExecutionLogTableSortColumns,
ExecutionLogTableSortColumns,
unknown
>(
'DefaultSortField',
executionLogTableSortColumns.is,
(input, context): Either<t.Errors, ExecutionLogTableSortColumns> =>
input == null ? t.success('timestamp') : executionLogTableSortColumns.validate(input, context),
t.identity
);
const sortOrder = t.keyof({ asc: null, desc: null });
type SortOrder = t.TypeOf<typeof sortOrder>;
/**
* Types the DefaultSortOrder as:
* - If undefined, then a default sort order of 'desc' will be set
* - If a string is sent in, then the string will be validated to ensure it is as valid sortOrder
*/
export const DefaultSortOrder = new t.Type<SortOrder, SortOrder, unknown>(
'DefaultSortOrder',
sortOrder.is,
(input, context): Either<t.Errors, SortOrder> =>
input == null ? t.success('desc') : sortOrder.validate(input, context),
t.identity
);
/**
* Route Request Params
*/
export const GetRuleExecutionEventsRequestParams = t.exact(
t.type({
ruleId: t.string,
})
);
/**
* Route Query Params (as constructed from the above codecs)
*/
export const GetRuleExecutionEventsQueryParams = t.exact(
t.type({
start: IsoDateString,
end: IsoDateString,
query_text: DefaultEmptyString, // default to "" if not sent in during decode
status_filters: DefaultStatusFiltersStringArray, // defaults to empty array if not sent in during decode
per_page: DefaultPerPage, // defaults to "20" if not sent in during decode
page: DefaultPage, // defaults to "1" if not sent in during decode
sort_field: DefaultSortField, // defaults to "desc" if not sent in during decode
sort_order: DefaultSortOrder, // defaults to "timestamp" if not sent in during decode
})
);
export type GetRuleExecutionEventsRequestParams = t.TypeOf<
typeof GetRuleExecutionEventsRequestParams
>;

View file

@ -12,9 +12,9 @@ export * from './find_rules_schema';
export * from './import_rules_schema';
export * from './patch_rules_bulk_schema';
export * from './patch_rules_schema';
export * from './perform_bulk_action_schema';
export * from './query_rules_schema';
export * from './query_signals_index_schema';
export * from './rule_schemas';
export * from './set_signal_status_schema';
export * from './update_rules_bulk_schema';
export * from './rule_schemas';
export * from './perform_bulk_action_schema';

View file

@ -29,6 +29,7 @@ import {
import { listArray } from '@kbn/securitysolution-io-ts-list-types';
import { version } from '@kbn/securitysolution-io-ts-types';
import { RuleExecutionSummary } from '../../rule_monitoring';
import {
id,
index,
@ -70,7 +71,6 @@ import {
created_at,
created_by,
namespace,
ruleExecutionSummary,
RelatedIntegrationArray,
RequiredFieldArray,
SetupGuide,
@ -486,7 +486,7 @@ const responseRequiredFields = {
};
const responseOptionalFields = {
execution_summary: ruleExecutionSummary,
execution_summary: RuleExecutionSummary,
};
export const fullResponseSchema = t.intersection([

View file

@ -7,7 +7,6 @@
export * from './error_schema';
export * from './get_installed_integrations_response_schema';
export * from './get_rule_execution_events_response';
export * from './import_rules_schema';
export * from './prepackaged_rules_schema';
export * from './prepackaged_rules_status_schema';

View file

@ -34,10 +34,11 @@ import {
max_signals,
} from '@kbn/securitysolution-io-ts-alerting-types';
import { DefaultStringArray, version } from '@kbn/securitysolution-io-ts-types';
import { DefaultListArray } from '@kbn/securitysolution-io-ts-list-types';
import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import { RuleExecutionSummary } from '../../rule_monitoring';
import {
anomaly_threshold,
data_view_id,
@ -77,7 +78,6 @@ import {
rule_name_override,
timestamp_override,
namespace,
ruleExecutionSummary,
RelatedIntegrationArray,
RequiredFieldArray,
SetupGuide,
@ -189,7 +189,7 @@ export const partialRulesSchema = t.partial({
namespace,
note,
uuid: id, // Move to 'required' post-migration
execution_summary: ruleExecutionSummary,
execution_summary: RuleExecutionSummary,
});
/**

View file

@ -37,6 +37,7 @@ export const allowedExperimentalValues = Object.freeze({
* Enables the Endpoint response actions console in various areas of the app
*/
responseActionsConsoleEnabled: true,
/**
* Enables the cloud security posture navigation inside the security solution
*/
@ -46,6 +47,15 @@ export const allowedExperimentalValues = Object.freeze({
* Enables the insights module for related alerts by process ancestry
*/
insightsRelatedAlertsByProcessAncestry: false,
/**
* Enables extended rule execution logging to Event Log. When this setting is enabled:
* - Rules write their console error, info, debug, and trace messages to Event Log,
* in addition to other events they log there (status changes and execution metrics).
* - We add a Kibana Advanced Setting that controls this behavior (on/off and log level).
* - We show a table with plain execution logs on the Rule Details page.
*/
extendedRuleExecutionLoggingEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

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 { enumFromString } from './enum_from_string';
describe('enumFromString', () => {
enum TestStringEnum {
'foo' = 'foo',
'bar' = 'bar',
}
const testEnumFromString = enumFromString(TestStringEnum);
it('returns enum if provided with a known value', () => {
expect(testEnumFromString('foo')).toEqual(TestStringEnum.foo);
expect(testEnumFromString('bar')).toEqual(TestStringEnum.bar);
});
it('returns null if provided with an unknown value', () => {
expect(testEnumFromString('xyz')).toEqual(null);
expect(testEnumFromString('123')).toEqual(null);
});
it('returns null if provided with null', () => {
expect(testEnumFromString(null)).toEqual(null);
});
it('returns null if provided with undefined', () => {
expect(testEnumFromString(undefined)).toEqual(null);
});
});

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
interface Enum<T> {
[s: string]: T;
}
/**
* WARNING: It works only with string enums.
* https://www.typescriptlang.org/docs/handbook/enums.html#string-enums
*
* Converts a string into a corresponding enum value.
* Returns null if the value is not in the enum.
*
* @param enm Specified enum.
* @returns Enum value or null.
*
* @example
* enum MyEnum {
* 'foo' = 'foo',
* 'bar' = 'bar',
* }
*
* const foo = enumFromString(MyEnum)('foo'); // MyEnum.foo
* const bar = enumFromString(MyEnum)('bar'); // MyEnum.bar
* const unknown = enumFromString(MyEnum)('xyz'); // null
*/
export const enumFromString = <T>(enm: Enum<T>) => {
const supportedEnumValues = Object.values(enm) as unknown as string[];
return (value: string | null | undefined): T | null => {
return value && supportedEnumValues.includes(value) ? (value as unknown as T) : null;
};
};

View file

@ -5,8 +5,9 @@
* 2.0.
*/
export const toArray = <T = string>(value: T | T[] | null): T[] =>
export const toArray = <T = string>(value: T | T[] | null | undefined): T[] =>
Array.isArray(value) ? value : value == null ? [] : [value];
export const toStringArray = <T = string>(value: T | T[] | null): string[] => {
if (Array.isArray(value)) {
return value.reduce<string[]>((acc, v) => {
@ -41,6 +42,7 @@ export const toStringArray = <T = string>(value: T | T[] | null): string[] => {
return [`${value}`];
}
};
export const toObjectArrayOfStrings = <T = string>(
value: T | T[] | null
): Array<{

View file

@ -6,9 +6,9 @@
*/
import React from 'react';
import styled from 'styled-components';
import type { EuiHealthProps } from '@elastic/eui';
import { EuiHealth, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
const StatusTextWrapper = styled.div`
width: 100%;

View file

@ -0,0 +1,26 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/detection_engine'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/detection_engine',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/security_solution/public/detection_engine/**/*.{ts,tsx}',
],
// See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core.
moduleNameMapper: {
'core/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/core.mock.ts',
'task_manager/server$':
'<rootDir>/x-pack/plugins/security_solution/server/__mocks__/task_manager.mock.ts',
'alerting/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/alert.mock.ts',
'actions/server$': '<rootDir>/x-pack/plugins/security_solution/server/__mocks__/action.mock.ts',
},
};

View file

@ -0,0 +1,73 @@
/*
* 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 {
GetRuleExecutionEventsResponse,
GetRuleExecutionResultsResponse,
} from '../../../../../common/detection_engine/rule_monitoring';
import {
LogLevel,
RuleExecutionEventType,
} from '../../../../../common/detection_engine/rule_monitoring';
import type {
FetchRuleExecutionEventsArgs,
FetchRuleExecutionResultsArgs,
IRuleMonitoringApiClient,
} from '../api_client_interface';
export const api: jest.Mocked<IRuleMonitoringApiClient> = {
fetchRuleExecutionEvents: jest
.fn<Promise<GetRuleExecutionEventsResponse>, [FetchRuleExecutionEventsArgs]>()
.mockResolvedValue({
events: [
{
timestamp: '2021-12-29T10:42:59.996Z',
sequence: 0,
level: LogLevel.info,
type: RuleExecutionEventType['status-change'],
message: 'Rule changed status to "succeeded". Rule execution completed without errors',
},
],
pagination: {
page: 1,
per_page: 20,
total: 1,
},
}),
fetchRuleExecutionResults: jest
.fn<Promise<GetRuleExecutionResultsResponse>, [FetchRuleExecutionResultsArgs]>()
.mockResolvedValue({
events: [
{
duration_ms: 3866,
es_search_duration_ms: 1236,
execution_uuid: '88d15095-7937-462c-8f21-9763e1387cad',
gap_duration_s: 0,
indexing_duration_ms: 95,
message:
"rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'",
num_active_alerts: 0,
num_errored_actions: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_succeeded_actions: 1,
num_triggered_actions: 1,
schedule_delay_ms: -127535,
search_duration_ms: 1255,
security_message: 'succeeded',
security_status: 'succeeded',
status: 'success',
timed_out: false,
timestamp: '2022-03-13T06:04:05.838Z',
total_search_duration_ms: 0,
},
],
total: 1,
}),
};

View file

@ -0,0 +1,8 @@
/*
* 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 * from './api_client';

View file

@ -0,0 +1,138 @@
/*
* 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 { KibanaServices } from '../../../common/lib/kibana';
import type {
GetRuleExecutionEventsResponse,
GetRuleExecutionResultsResponse,
} from '../../../../common/detection_engine/rule_monitoring';
import {
LogLevel,
RuleExecutionEventType,
} from '../../../../common/detection_engine/rule_monitoring';
import { api } from './api_client';
jest.mock('../../../common/lib/kibana');
describe('Rule Monitoring API Client', () => {
const fetchMock = jest.fn();
const mockKibanaServices = KibanaServices.get as jest.Mock;
mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } });
const signal = new AbortController().signal;
describe('fetchRuleExecutionEvents', () => {
const responseMock: GetRuleExecutionEventsResponse = {
events: [],
pagination: {
page: 1,
per_page: 20,
total: 0,
},
};
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(responseMock);
});
it('calls API correctly with only rule id specified', async () => {
await api.fetchRuleExecutionEvents({ ruleId: '42', signal });
expect(fetchMock).toHaveBeenCalledWith(
'/internal/detection_engine/rules/42/execution/events',
{
method: 'GET',
query: {},
signal,
}
);
});
it('calls API correctly with filter and pagination options', async () => {
await api.fetchRuleExecutionEvents({
ruleId: '42',
eventTypes: [RuleExecutionEventType.message],
logLevels: [LogLevel.warn, LogLevel.error],
sortOrder: 'asc',
page: 42,
perPage: 146,
signal,
});
expect(fetchMock).toHaveBeenCalledWith(
'/internal/detection_engine/rules/42/execution/events',
{
method: 'GET',
query: {
event_types: 'message',
log_levels: 'warn,error',
sort_order: 'asc',
page: 42,
per_page: 146,
},
signal,
}
);
});
});
describe('fetchRuleExecutionResults', () => {
const responseMock: GetRuleExecutionResultsResponse = {
events: [],
total: 0,
};
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(responseMock);
});
it('calls API with correct parameters', async () => {
await api.fetchRuleExecutionResults({
ruleId: '42',
start: '2001-01-01T17:00:00.000Z',
end: '2001-01-02T17:00:00.000Z',
queryText: '',
statusFilters: [],
signal,
});
expect(fetchMock).toHaveBeenCalledWith(
'/internal/detection_engine/rules/42/execution/results',
{
method: 'GET',
query: {
end: '2001-01-02T17:00:00.000Z',
page: undefined,
per_page: undefined,
query_text: '',
sort_field: undefined,
sort_order: undefined,
start: '2001-01-01T17:00:00.000Z',
status_filters: '',
},
signal,
}
);
});
it('returns API response as is', async () => {
const response = await api.fetchRuleExecutionResults({
ruleId: '42',
start: 'now-30',
end: 'now',
queryText: '',
statusFilters: [],
signal,
});
expect(response).toEqual(responseMock);
});
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 dateMath from '@kbn/datemath';
import { KibanaServices } from '../../../common/lib/kibana';
import type {
GetRuleExecutionEventsResponse,
GetRuleExecutionResultsResponse,
} from '../../../../common/detection_engine/rule_monitoring';
import {
getRuleExecutionEventsUrl,
getRuleExecutionResultsUrl,
} from '../../../../common/detection_engine/rule_monitoring';
import type {
FetchRuleExecutionEventsArgs,
FetchRuleExecutionResultsArgs,
IRuleMonitoringApiClient,
} from './api_client_interface';
export const api: IRuleMonitoringApiClient = {
fetchRuleExecutionEvents: (
args: FetchRuleExecutionEventsArgs
): Promise<GetRuleExecutionEventsResponse> => {
const { ruleId, eventTypes, logLevels, sortOrder, page, perPage, signal } = args;
const url = getRuleExecutionEventsUrl(ruleId);
return http().fetch<GetRuleExecutionEventsResponse>(url, {
method: 'GET',
query: {
event_types: eventTypes?.join(','),
log_levels: logLevels?.join(','),
sort_order: sortOrder,
page,
per_page: perPage,
},
signal,
});
},
fetchRuleExecutionResults: (
args: FetchRuleExecutionResultsArgs
): Promise<GetRuleExecutionResultsResponse> => {
const {
ruleId,
start,
end,
queryText,
statusFilters,
page,
perPage,
sortField,
sortOrder,
signal,
} = args;
const url = getRuleExecutionResultsUrl(ruleId);
const startDate = dateMath.parse(start);
const endDate = dateMath.parse(end, { roundUp: true });
return http().fetch<GetRuleExecutionResultsResponse>(url, {
method: 'GET',
query: {
start: startDate?.utc().toISOString(),
end: endDate?.utc().toISOString(),
query_text: queryText,
status_filters: statusFilters?.sort()?.join(','),
sort_field: sortField,
sort_order: sortOrder,
page,
per_page: perPage,
},
signal,
});
},
};
const http = () => KibanaServices.get().http;

View file

@ -0,0 +1,124 @@
/*
* 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 { SortOrder } from '../../../../common/detection_engine/schemas/common';
import type {
GetRuleExecutionEventsResponse,
GetRuleExecutionResultsResponse,
LogLevel,
RuleExecutionEventType,
RuleExecutionResult,
RuleExecutionStatus,
} from '../../../../common/detection_engine/rule_monitoring';
export interface IRuleMonitoringApiClient {
/**
* Fetches plain rule execution events (status changes, metrics, generic events) from Event Log.
* @throws An error if response is not OK.
*/
fetchRuleExecutionEvents(
args: FetchRuleExecutionEventsArgs
): Promise<GetRuleExecutionEventsResponse>;
/**
* Fetches aggregated rule execution results (events grouped by execution UUID) from Event Log.
* @throws An error if response is not OK.
*/
fetchRuleExecutionResults(
args: FetchRuleExecutionResultsArgs
): Promise<GetRuleExecutionResultsResponse>;
}
export interface FetchRuleExecutionEventsArgs {
/**
* Saved Object ID of the rule (`rule.id`, not static `rule.rule_id`).
*/
ruleId: string;
/**
* Filter by event type. If set, result will include only events matching any of these.
*/
eventTypes?: RuleExecutionEventType[];
/**
* Filter by log level. If set, result will include only events matching any of these.
*/
logLevels?: LogLevel[];
/**
* What order to sort by (e.g. `asc` or `desc`).
*/
sortOrder?: SortOrder;
/**
* Current page to fetch.
*/
page?: number;
/**
* Number of results to fetch per page.
*/
perPage?: number;
/**
* Optional signal for cancelling the request.
*/
signal?: AbortSignal;
}
export interface FetchRuleExecutionResultsArgs {
/**
* Saved Object ID of the rule (`rule.id`, not static `rule.rule_id`).
*/
ruleId: string;
/**
* Start daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now-30`).
*/
start: string;
/**
* End daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now/w`).
*/
end: string;
/**
* Search string in querystring format, e.g.
* `event.duration > 1000 OR kibana.alert.rule.execution.metrics.execution_gap_duration_s > 100`.
*/
queryText?: string;
/**
* Array of `statusFilters` (e.g. `succeeded,failed,partial failure`).
*/
statusFilters?: RuleExecutionStatus[];
/**
* Keyof AggregateRuleExecutionEvent field to sort by.
*/
sortField?: keyof RuleExecutionResult;
/**
* What order to sort by (e.g. `asc` or `desc`).
*/
sortOrder?: SortOrder;
/**
* Current page to fetch.
*/
page?: number;
/**
* Number of results to fetch per page.
*/
perPage?: number;
/**
* Optional signal for cancelling the request.
*/
signal?: AbortSignal;
}

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.
*/
export * from './api_client_interface';
export * from './api_client';

View file

@ -0,0 +1,40 @@
/*
* 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, { useCallback } from 'react';
import type { RuleExecutionEventType } from '../../../../../../../common/detection_engine/rule_monitoring';
import { RULE_EXECUTION_EVENT_TYPES } from '../../../../../../../common/detection_engine/rule_monitoring';
import { EventTypeIndicator } from '../../indicators/event_type_indicator';
import { MultiselectFilter } from '../multiselect_filter';
import * as i18n from './translations';
interface EventTypeFilterProps {
selectedItems: RuleExecutionEventType[];
onChange: (selectedItems: RuleExecutionEventType[]) => void;
}
const EventTypeFilterComponent: React.FC<EventTypeFilterProps> = ({ selectedItems, onChange }) => {
const renderItem = useCallback((item: RuleExecutionEventType) => {
return <EventTypeIndicator type={item} />;
}, []);
return (
<MultiselectFilter<RuleExecutionEventType>
dataTestSubj="eventTypeFilter"
title={i18n.FILTER_TITLE}
items={RULE_EXECUTION_EVENT_TYPES}
selectedItems={selectedItems}
onSelectionChange={onChange}
renderItem={renderItem}
/>
);
};
export const EventTypeFilter = React.memo(EventTypeFilterComponent);
EventTypeFilter.displayName = 'EventTypeFilter';

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { buildRuleMessageFactory } from './rule_messages';
import { i18n } from '@kbn/i18n';
export const buildRuleMessageMock = buildRuleMessageFactory({
id: 'fake id',
ruleId: 'fake rule id',
index: 'fakeindex',
name: 'fake name',
});
export const FILTER_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.eventTypeFilter.filterTitle',
{
defaultMessage: 'Event type',
}
);

View file

@ -0,0 +1,44 @@
/*
* 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, { useCallback } from 'react';
import type { RuleExecutionStatus } from '../../../../../../../common/detection_engine/rule_monitoring';
import { ExecutionStatusIndicator } from '../../indicators/execution_status_indicator';
import { MultiselectFilter } from '../multiselect_filter';
import * as i18n from './translations';
interface ExecutionStatusFilterProps {
items: RuleExecutionStatus[];
selectedItems: RuleExecutionStatus[];
onChange: (selectedItems: RuleExecutionStatus[]) => void;
}
const ExecutionStatusFilterComponent: React.FC<ExecutionStatusFilterProps> = ({
items,
selectedItems,
onChange,
}) => {
const renderItem = useCallback((item: RuleExecutionStatus) => {
return <ExecutionStatusIndicator status={item} />;
}, []);
return (
<MultiselectFilter<RuleExecutionStatus>
dataTestSubj="ExecutionStatusFilter"
title={i18n.FILTER_TITLE}
items={items}
selectedItems={selectedItems}
onSelectionChange={onChange}
renderItem={renderItem}
/>
);
};
export const ExecutionStatusFilter = React.memo(ExecutionStatusFilterComponent);
ExecutionStatusFilter.displayName = 'ExecutionStatusFilter';

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { buildRuleMessageFactory } from '../rule_messages';
import { i18n } from '@kbn/i18n';
export const mockBuildRuleMessage = buildRuleMessageFactory({
id: 'fake id',
ruleId: 'fake rule id',
index: 'fakeindex',
name: 'fake name',
});
export const FILTER_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionStatusFilter.filterTitle',
{
defaultMessage: 'Status',
}
);

View file

@ -0,0 +1,40 @@
/*
* 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, { useCallback } from 'react';
import type { LogLevel } from '../../../../../../../common/detection_engine/rule_monitoring';
import { LOG_LEVELS } from '../../../../../../../common/detection_engine/rule_monitoring';
import { LogLevelIndicator } from '../../indicators/log_level_indicator';
import { MultiselectFilter } from '../multiselect_filter';
import * as i18n from './translations';
interface LogLevelFilterProps {
selectedItems: LogLevel[];
onChange: (selectedItems: LogLevel[]) => void;
}
const LogLevelFilterComponent: React.FC<LogLevelFilterProps> = ({ selectedItems, onChange }) => {
const renderItem = useCallback((item: LogLevel) => {
return <LogLevelIndicator logLevel={item} />;
}, []);
return (
<MultiselectFilter<LogLevel>
dataTestSubj="logLevelFilter"
title={i18n.FILTER_TITLE}
items={LOG_LEVELS}
selectedItems={selectedItems}
onSelectionChange={onChange}
renderItem={renderItem}
/>
);
};
export const LogLevelFilter = React.memo(LogLevelFilterComponent);
LogLevelFilter.displayName = 'LogLevelFilter';

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FILTER_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.logLevelFilter.filterTitle',
{
defaultMessage: 'Log level',
}
);

View file

@ -0,0 +1,112 @@
/*
* 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, { useCallback, useMemo } from 'react';
import { noop } from 'lodash';
import { EuiPopover, EuiFilterGroup, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
import { useBoolState } from '../../../../../../common/hooks/use_bool_state';
export interface MultiselectFilterProps<T = unknown> {
dataTestSubj?: string;
title: string;
items: T[];
selectedItems: T[];
onSelectionChange?: (selectedItems: T[]) => void;
renderItem?: (item: T) => React.ReactChild;
renderLabel?: (item: T) => string;
}
const MultiselectFilterComponent = <T extends unknown>(props: MultiselectFilterProps<T>) => {
const { dataTestSubj, title, items, selectedItems, onSelectionChange, renderItem, renderLabel } =
initializeProps(props);
const [isPopoverOpen, , closePopover, togglePopover] = useBoolState();
const handleItemClick = useCallback(
(item: T) => {
const newSelectedItems = selectedItems.includes(item)
? selectedItems.filter((i) => i !== item)
: [...selectedItems, item];
onSelectionChange(newSelectedItems);
},
[selectedItems, onSelectionChange]
);
const filterItemElements = useMemo(() => {
return items.map((item, index) => {
const itemLabel = renderLabel(item);
const itemElement = renderItem(item);
return (
<EuiFilterSelectItem
data-test-subj={`${dataTestSubj}-item`}
title={itemLabel}
key={`${index}-${itemLabel}`}
checked={selectedItems.includes(item) ? 'on' : undefined}
onClick={() => handleItemClick(item)}
>
{itemElement}
</EuiFilterSelectItem>
);
});
}, [dataTestSubj, items, selectedItems, renderItem, renderLabel, handleItemClick]);
return (
<EuiFilterGroup data-test-subj={dataTestSubj}>
<EuiPopover
data-test-subj={`${dataTestSubj}-popover`}
button={
<EuiFilterButton
data-test-subj={`${dataTestSubj}-popoverButton`}
iconType="arrowDown"
grow={false}
numFilters={items.length}
numActiveFilters={selectedItems.length}
hasActiveFilters={selectedItems.length > 0}
isSelected={isPopoverOpen}
onClick={togglePopover}
>
{title}
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
repositionOnScroll
>
{filterItemElements}
</EuiPopover>
</EuiFilterGroup>
);
};
// We have to wrap it in a function and cast to original type because React.memo
// returns a component type which is not generic.
const enhanceMultiselectFilterComponent = () => {
const Component = React.memo(MultiselectFilterComponent);
Component.displayName = 'MultiselectFilter';
return Component as typeof MultiselectFilterComponent;
};
export const MultiselectFilter = enhanceMultiselectFilterComponent();
const initializeProps = <T extends unknown>(
props: MultiselectFilterProps<T>
): Required<MultiselectFilterProps<T>> => {
const onSelectionChange: (selectedItems: T[]) => void = props.onSelectionChange ?? noop;
const renderLabel: (item: T) => string = props.renderLabel ?? String;
const renderItem: (item: T) => React.ReactChild = props.renderItem ?? renderLabel;
return {
dataTestSubj: props.dataTestSubj ?? 'multiselectFilter',
title: props.title,
items: props.items,
selectedItems: props.selectedItems,
onSelectionChange,
renderLabel,
renderItem,
};
};

View file

@ -0,0 +1,29 @@
/*
* 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 from 'react';
import { EuiBadge } from '@elastic/eui';
import type { RuleExecutionEventType } from '../../../../../../../common/detection_engine/rule_monitoring';
import { getBadgeIcon, getBadgeText } from './utils';
interface EventTypeIndicatorProps {
type: RuleExecutionEventType;
}
const EventTypeIndicatorComponent: React.FC<EventTypeIndicatorProps> = ({ type }) => {
const icon = getBadgeIcon(type);
const text = getBadgeText(type);
return (
<EuiBadge color="hollow" iconType={icon}>
{text}
</EuiBadge>
);
};
export const EventTypeIndicator = React.memo(EventTypeIndicatorComponent);
EventTypeIndicator.displayName = 'EventTypeIndicator';

View file

@ -0,0 +1,29 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const TYPE_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.eventTypeIndicator.messageText',
{
defaultMessage: 'Message',
}
);
export const TYPE_STATUS_CHANGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.eventTypeIndicator.statusChangeText',
{
defaultMessage: 'Status',
}
);
export const TYPE_EXECUTION_METRICS = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.eventTypeIndicator.executionMetricsText',
{
defaultMessage: 'Metrics',
}
);

View file

@ -0,0 +1,38 @@
/*
* 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 { IconType } from '@elastic/eui';
import { RuleExecutionEventType } from '../../../../../../../common/detection_engine/rule_monitoring';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import * as i18n from './translations';
export const getBadgeIcon = (type: RuleExecutionEventType): IconType => {
switch (type) {
case RuleExecutionEventType.message:
return 'console';
case RuleExecutionEventType['status-change']:
return 'dot';
case RuleExecutionEventType['execution-metrics']:
return 'gear';
default:
return assertUnreachable(type, 'Unknown rule execution event type');
}
};
export const getBadgeText = (type: RuleExecutionEventType): string => {
switch (type) {
case RuleExecutionEventType.message:
return i18n.TYPE_MESSAGE;
case RuleExecutionEventType['status-change']:
return i18n.TYPE_STATUS_CHANGE;
case RuleExecutionEventType['execution-metrics']:
return i18n.TYPE_EXECUTION_METRICS;
default:
return assertUnreachable(type, 'Unknown rule execution event type');
}
};

View file

@ -0,0 +1,41 @@
/*
* 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 from 'react';
import { EuiHealth } from '@elastic/eui';
import type { RuleExecutionStatus } from '../../../../../../../common/detection_engine/rule_monitoring';
import { getEmptyTagValue } from '../../../../../../common/components/empty_value';
import { RuleStatusBadge } from '../../../../../../detections/components/rules/rule_execution_status';
import {
getCapitalizedStatusText,
getStatusColor,
} from '../../../../../../detections/components/rules/rule_execution_status/utils';
const EMPTY_STATUS_TEXT = getEmptyTagValue();
interface ExecutionStatusIndicatorProps {
status?: RuleExecutionStatus | null | undefined;
showTooltip?: boolean;
}
const ExecutionStatusIndicatorComponent: React.FC<ExecutionStatusIndicatorProps> = ({
status,
showTooltip = false,
}) => {
const statusText = getCapitalizedStatusText(status) ?? EMPTY_STATUS_TEXT;
const statusColor = getStatusColor(status);
return showTooltip ? (
<RuleStatusBadge status={status} />
) : (
<EuiHealth color={statusColor}>{statusText}</EuiHealth>
);
};
export const ExecutionStatusIndicator = React.memo(ExecutionStatusIndicatorComponent);
ExecutionStatusIndicator.displayName = 'ExecutionStatusIndicator';

View file

@ -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 React from 'react';
import { EuiBadge } from '@elastic/eui';
import type { LogLevel } from '../../../../../../../common/detection_engine/rule_monitoring';
import { getBadgeColor, getBadgeText } from './utils';
interface LogLevelIndicatorProps {
logLevel: LogLevel;
}
const LogLevelIndicatorComponent: React.FC<LogLevelIndicatorProps> = ({ logLevel }) => {
const color = getBadgeColor(logLevel);
const text = getBadgeText(logLevel);
return <EuiBadge color={color}>{text}</EuiBadge>;
};
export const LogLevelIndicator = React.memo(LogLevelIndicatorComponent);
LogLevelIndicator.displayName = 'LogLevelIndicator';

View file

@ -0,0 +1,32 @@
/*
* 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 { upperCase } from 'lodash';
import type { IconColor } from '@elastic/eui';
import { LogLevel } from '../../../../../../../common/detection_engine/rule_monitoring';
import { assertUnreachable } from '../../../../../../../common/utility_types';
export const getBadgeColor = (logLevel: LogLevel): IconColor => {
switch (logLevel) {
case LogLevel.trace:
return 'hollow';
case LogLevel.debug:
return 'hollow';
case LogLevel.info:
return 'default';
case LogLevel.warn:
return 'warning';
case LogLevel.error:
return 'danger';
default:
return assertUnreachable(logLevel, 'Unknown log level');
}
};
export const getBadgeText = (logLevel: LogLevel): string => {
return upperCase(logLevel);
};

View file

@ -0,0 +1,57 @@
/*
* 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 React from 'react';
import { useCallback, useMemo, useState } from 'react';
type TableItem = Record<string, unknown>;
type TableItemId = string;
type TableItemRowMap = Record<TableItemId, React.ReactNode>;
interface UseExpandableRowsArgs<T> {
getItemId: (item: T) => TableItemId;
renderItem: (item: T) => React.ReactChild;
}
export const useExpandableRows = <T extends TableItem>(args: UseExpandableRowsArgs<T>) => {
const { getItemId, renderItem } = args;
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<TableItemRowMap>({});
const toggleRowExpanded = useCallback(
(item: T) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
const itemId = getItemId(item);
if (itemIdToExpandedRowMapValues[itemId]) {
delete itemIdToExpandedRowMapValues[itemId];
} else {
itemIdToExpandedRowMapValues[itemId] = renderItem(item);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[itemIdToExpandedRowMap, getItemId, renderItem]
);
const isRowExpanded = useCallback(
(item: T): boolean => {
const itemId = getItemId(item);
return itemIdToExpandedRowMap[itemId] != null;
},
[itemIdToExpandedRowMap, getItemId]
);
return useMemo(() => {
return {
itemIdToExpandedRowMap,
getItemId,
toggleRowExpanded,
isRowExpanded,
};
}, [itemIdToExpandedRowMap, getItemId, toggleRowExpanded, isRowExpanded]);
};

View file

@ -0,0 +1,60 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import type { CriteriaWithPagination } from '@elastic/eui';
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50];
const DEFAULT_PAGE_NUMBER = 1;
interface UsePaginationArgs {
pageSizeOptions?: number[];
pageSizeDefault?: number;
pageNumberDefault?: number;
}
type TableItem = Record<string, unknown>;
export const usePagination = <T extends TableItem>(args: UsePaginationArgs) => {
const pageSizeOptions = args.pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS;
const pageSizeDefault = args.pageSizeDefault ?? pageSizeOptions[0];
const pageNumberDefault = args.pageNumberDefault ?? DEFAULT_PAGE_NUMBER;
const [pageSize, setPageSize] = useState(pageSizeDefault);
const [pageNumber, setPageNumber] = useState(pageNumberDefault);
const [totalItemCount, setTotalItemCount] = useState(0);
const state = useMemo(() => {
return {
pageSizeOptions,
pageSize,
pageNumber,
pageIndex: pageNumber - 1,
totalItemCount,
};
}, [pageSizeOptions, pageSize, pageNumber, totalItemCount]);
const update = useCallback(
(criteria: CriteriaWithPagination<T>): void => {
setPageNumber(criteria.page.index + 1);
setPageSize(criteria.page.size);
},
[setPageNumber, setPageSize]
);
const updateTotalItemCount = useCallback(
(count: number | null | undefined): void => {
setTotalItemCount(count ?? 0);
},
[setTotalItemCount]
);
return useMemo(
() => ({ state, update, updateTotalItemCount }),
[state, update, updateTotalItemCount]
);
};

View file

@ -0,0 +1,38 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import type { CriteriaWithPagination } from '@elastic/eui';
import type { SortOrder } from '../../../../../../common/detection_engine/schemas/common';
type TableItem = Record<string, unknown>;
export const useSorting = <T extends TableItem>(defaultField: keyof T, defaultOrder: SortOrder) => {
const [sortField, setSortField] = useState<keyof T>(defaultField);
const [sortOrder, setSortOrder] = useState<SortOrder>(defaultOrder);
const state = useMemo(() => {
return {
sort: {
field: sortField,
direction: sortOrder,
},
};
}, [sortField, sortOrder]);
const update = useCallback(
(criteria: CriteriaWithPagination<T>): void => {
if (criteria.sort) {
setSortField(criteria.sort.field);
setSortOrder(criteria.sort.direction);
}
},
[setSortField, setSortOrder]
);
return useMemo(() => ({ state, update }), [state, update]);
};

View file

@ -0,0 +1,31 @@
/*
* 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 from 'react';
import { EuiCodeBlock } from '@elastic/eui';
const DEFAULT_OVERFLOW_HEIGHT = 320;
interface TextBlockProps {
text: string | null | undefined;
}
const TextBlockComponent: React.FC<TextBlockProps> = ({ text }) => {
return (
<EuiCodeBlock
className="eui-fullWidth"
isCopyable
overflowHeight={DEFAULT_OVERFLOW_HEIGHT}
transparentBackground
>
{text ?? ''}
</EuiCodeBlock>
);
};
export const TextBlock = React.memo(TextBlockComponent);
TextBlock.displayName = 'TextBlock';

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.
*/
import React from 'react';
interface TruncatedTextProps {
text: string | null | undefined;
}
const TruncatedTextComponent: React.FC<TruncatedTextProps> = ({ text }) => {
return text != null ? <span className="eui-fullWidth eui-textTruncate">{text}</span> : null;
};
export const TruncatedText = React.memo(TruncatedTextComponent);
TruncatedText.displayName = 'TruncatedText';

View file

@ -0,0 +1,117 @@
/*
* 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, { useCallback, useEffect, useMemo } from 'react';
import type { CriteriaWithPagination } from '@elastic/eui';
import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import type { RuleExecutionEvent } from '../../../../../common/detection_engine/rule_monitoring';
import { HeaderSection } from '../../../../common/components/header_section';
import { EventTypeFilter } from '../basic/filters/event_type_filter';
import { LogLevelFilter } from '../basic/filters/log_level_filter';
import { ExecutionEventsTableRowDetails } from './execution_events_table_row_details';
import { useFilters } from './use_filters';
import { useSorting } from '../basic/tables/use_sorting';
import { usePagination } from '../basic/tables/use_pagination';
import { useColumns } from './use_columns';
import { useExpandableRows } from '../basic/tables/use_expandable_rows';
import { useExecutionEvents } from './use_execution_events';
import * as i18n from './translations';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200];
interface ExecutionEventsTableProps {
ruleId: string;
}
const ExecutionEventsTableComponent: React.FC<ExecutionEventsTableProps> = ({ ruleId }) => {
const getItemId = useCallback((item: RuleExecutionEvent): string => {
return `${item.timestamp} ${item.sequence}`;
}, []);
const renderExpandedItem = useCallback((item: RuleExecutionEvent) => {
return <ExecutionEventsTableRowDetails item={item} />;
}, []);
const rows = useExpandableRows<RuleExecutionEvent>({
getItemId,
renderItem: renderExpandedItem,
});
const columns = useColumns({
toggleRowExpanded: rows.toggleRowExpanded,
isRowExpanded: rows.isRowExpanded,
});
const filters = useFilters();
const sorting = useSorting<RuleExecutionEvent>('timestamp', 'desc');
const pagination = usePagination<RuleExecutionEvent>({ pageSizeOptions: PAGE_SIZE_OPTIONS });
const executionEvents = useExecutionEvents({
ruleId,
eventTypes: filters.state.eventTypes,
logLevels: filters.state.logLevels,
sortOrder: sorting.state.sort.direction,
page: pagination.state.pageNumber,
perPage: pagination.state.pageSize,
});
// Each time execution events are fetched
useEffect(() => {
// We need to update total item count for the pagination to work properly
pagination.updateTotalItemCount(executionEvents.data?.pagination.total);
}, [executionEvents, pagination]);
const items = useMemo(() => executionEvents.data?.events ?? [], [executionEvents.data]);
const handleTableChange = useCallback(
(criteria: CriteriaWithPagination<RuleExecutionEvent>): void => {
sorting.update(criteria);
pagination.update(criteria);
},
[sorting, pagination]
);
return (
<EuiPanel hasBorder>
{/* Filter bar */}
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={true}>
<HeaderSection title={i18n.TABLE_TITLE} subtitle={i18n.TABLE_SUBTITLE} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogLevelFilter selectedItems={filters.state.logLevels} onChange={filters.setLogLevels} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EventTypeFilter
selectedItems={filters.state.eventTypes}
onChange={filters.setEventTypes}
/>
</EuiFlexItem>
</EuiFlexGroup>
{/* Table with items */}
<EuiBasicTable
columns={columns}
items={items}
itemId={getItemId}
itemIdToExpandedRowMap={rows.itemIdToExpandedRowMap}
isExpandable={true}
loading={executionEvents.isFetching}
sorting={sorting.state}
pagination={pagination.state}
onChange={handleTableChange}
/>
</EuiPanel>
);
};
export const ExecutionEventsTable = React.memo(ExecutionEventsTableComponent);
ExecutionEventsTable.displayName = 'RuleExecutionEventsTable';

View file

@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import type { RuleExecutionEvent } from '../../../../../common/detection_engine/rule_monitoring';
import { TextBlock } from '../basic/text/text_block';
import * as i18n from './translations';
interface ExecutionEventsTableRowDetailsProps {
item: RuleExecutionEvent;
}
const ExecutionEventsTableRowDetailsComponent: React.FC<ExecutionEventsTableRowDetailsProps> = ({
item,
}) => {
return (
<EuiDescriptionList
className="eui-fullWidth"
listItems={[
{
title: i18n.ROW_DETAILS_MESSAGE,
description: <TextBlock text={item.message} />,
},
{
title: i18n.ROW_DETAILS_JSON,
description: <TextBlock text={JSON.stringify(item, null, 2)} />,
},
]}
/>
);
};
export const ExecutionEventsTableRowDetails = React.memo(ExecutionEventsTableRowDetailsComponent);
ExecutionEventsTableRowDetails.displayName = 'ExecutionEventsTableRowDetails';

View file

@ -0,0 +1,71 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const TABLE_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.tableTitle',
{
defaultMessage: 'Execution log',
}
);
export const TABLE_SUBTITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.tableSubtitle',
{
defaultMessage: 'A detailed log of rule execution events',
}
);
export const COLUMN_TIMESTAMP = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.timestampColumn',
{
defaultMessage: 'Timestamp',
}
);
export const COLUMN_LOG_LEVEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.logLevelColumn',
{
defaultMessage: 'Level',
}
);
export const COLUMN_EVENT_TYPE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.eventTypeColumn',
{
defaultMessage: 'Type',
}
);
export const COLUMN_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.messageColumn',
{
defaultMessage: 'Message',
}
);
export const FETCH_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.fetchErrorDescription',
{
defaultMessage: 'Failed to fetch rule execution events',
}
);
export const ROW_DETAILS_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.rowDetails.messageTitle',
{
defaultMessage: 'Message',
}
);
export const ROW_DETAILS_JSON = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionEventsTable.rowDetails.jsonTitle',
{
defaultMessage: 'Full JSON',
}
);

View file

@ -0,0 +1,100 @@
/*
* 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, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiButtonIcon, EuiScreenReaderOnly, RIGHT_ALIGNMENT } from '@elastic/eui';
import type {
LogLevel,
RuleExecutionEvent,
RuleExecutionEventType,
} from '../../../../../common/detection_engine/rule_monitoring';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { EventTypeIndicator } from '../basic/indicators/event_type_indicator';
import { LogLevelIndicator } from '../basic/indicators/log_level_indicator';
import { TruncatedText } from '../basic/text/truncated_text';
import * as i18n from './translations';
type TableColumn = EuiBasicTableColumn<RuleExecutionEvent>;
interface UseColumnsArgs {
toggleRowExpanded: (item: RuleExecutionEvent) => void;
isRowExpanded: (item: RuleExecutionEvent) => boolean;
}
export const useColumns = (args: UseColumnsArgs): TableColumn[] => {
const { toggleRowExpanded, isRowExpanded } = args;
return useMemo(() => {
return [
timestampColumn,
logLevelColumn,
eventTypeColumn,
messageColumn,
expanderColumn({ toggleRowExpanded, isRowExpanded }),
];
}, [toggleRowExpanded, isRowExpanded]);
};
const timestampColumn: TableColumn = {
field: 'timestamp',
name: i18n.COLUMN_TIMESTAMP,
render: (value: string) => <FormattedDate value={value} fieldName="timestamp" />,
sortable: true,
truncateText: false,
width: '20%',
};
const logLevelColumn: TableColumn = {
field: 'level',
name: i18n.COLUMN_LOG_LEVEL,
render: (value: LogLevel) => <LogLevelIndicator logLevel={value} />,
sortable: false,
truncateText: false,
width: '8%',
};
const eventTypeColumn: TableColumn = {
field: 'type',
name: i18n.COLUMN_EVENT_TYPE,
render: (value: RuleExecutionEventType) => <EventTypeIndicator type={value} />,
sortable: false,
truncateText: false,
width: '8%',
};
const messageColumn: TableColumn = {
field: 'message',
name: i18n.COLUMN_MESSAGE,
render: (value: string) => <TruncatedText text={value} />,
sortable: false,
truncateText: true,
width: '64%',
};
const expanderColumn = ({ toggleRowExpanded, isRowExpanded }: UseColumnsArgs): TableColumn => {
return {
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
name: (
<EuiScreenReaderOnly>
<span>{'Expand rows'}</span>
</EuiScreenReaderOnly>
),
render: (item: RuleExecutionEvent) => (
<EuiButtonIcon
onClick={() => toggleRowExpanded(item)}
aria-label={isRowExpanded(item) ? 'Collapse' : 'Expand'}
iconType={isRowExpanded(item) ? 'arrowUp' : 'arrowDown'}
/>
),
};
};

View file

@ -0,0 +1,127 @@
/*
* 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 from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, cleanup } from '@testing-library/react-hooks';
import {
LogLevel,
RuleExecutionEventType,
} from '../../../../../common/detection_engine/rule_monitoring';
import { useExecutionEvents } from './use_execution_events';
import { useToasts } from '../../../../common/lib/kibana';
import { api } from '../../api';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../api');
const SOME_RULE_ID = 'some-rule-id';
describe('useExecutionEvents', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(async () => {
cleanup();
});
const createReactQueryWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Turn retries off, otherwise we won't be able to test errors
retry: false,
},
},
});
const wrapper: React.FC = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const render = () =>
renderHook(() => useExecutionEvents({ ruleId: SOME_RULE_ID }), {
wrapper: createReactQueryWrapper(),
});
it('calls the API via fetchRuleExecutionEvents', async () => {
const fetchRuleExecutionEvents = jest.spyOn(api, 'fetchRuleExecutionEvents');
const { waitForNextUpdate } = render();
await waitForNextUpdate();
expect(fetchRuleExecutionEvents).toHaveBeenCalledTimes(1);
expect(fetchRuleExecutionEvents).toHaveBeenLastCalledWith(
expect.objectContaining({ ruleId: SOME_RULE_ID })
);
});
it('fetches data from the API', async () => {
const { result, waitForNextUpdate } = render();
// It starts from a loading state
expect(result.current.isLoading).toEqual(true);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(false);
// When fetchRuleExecutionEvents returns
await waitForNextUpdate();
// It switches to a success state
expect(result.current.isLoading).toEqual(false);
expect(result.current.isSuccess).toEqual(true);
expect(result.current.isError).toEqual(false);
expect(result.current.data).toEqual({
events: [
{
timestamp: '2021-12-29T10:42:59.996Z',
sequence: 0,
level: LogLevel.info,
type: RuleExecutionEventType['status-change'],
message: 'Rule changed status to "succeeded". Rule execution completed without errors',
},
],
pagination: {
page: 1,
per_page: 20,
total: 1,
},
});
});
it('handles exceptions from the API', async () => {
const exception = new Error('Boom!');
jest.spyOn(api, 'fetchRuleExecutionEvents').mockRejectedValue(exception);
const { result, waitForNextUpdate } = render();
// It starts from a loading state
expect(result.current.isLoading).toEqual(true);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(false);
// When fetchRuleExecutionEvents throws
await waitForNextUpdate();
// It switches to an error state
expect(result.current.isLoading).toEqual(false);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(true);
expect(result.current.error).toEqual(exception);
// And shows a toast with the caught exception
expect(useToasts().addError).toHaveBeenCalledTimes(1);
expect(useToasts().addError).toHaveBeenCalledWith(exception, {
title: 'Failed to fetch rule execution events',
});
});
});

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 { useQuery } from 'react-query';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { GetRuleExecutionEventsResponse } from '../../../../../common/detection_engine/rule_monitoring';
import type { FetchRuleExecutionEventsArgs } from '../../api';
import { api } from '../../api';
import * as i18n from './translations';
export type UseExecutionEventsArgs = Omit<FetchRuleExecutionEventsArgs, 'signal'>;
export const useExecutionEvents = (args: UseExecutionEventsArgs) => {
const { addError } = useAppToasts();
return useQuery<GetRuleExecutionEventsResponse>(
['detectionEngine', 'ruleMonitoring', 'executionEvents', args],
({ signal }) => {
return api.fetchRuleExecutionEvents({ ...args, signal });
},
{
keepPreviousData: true,
refetchInterval: 20000,
onError: (e) => {
addError(e, { title: i18n.FETCH_ERROR });
},
}
);
};

View file

@ -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 { useMemo, useState } from 'react';
import type {
LogLevel,
RuleExecutionEventType,
} from '../../../../../common/detection_engine/rule_monitoring';
export const useFilters = () => {
const [logLevels, setLogLevels] = useState<LogLevel[]>([]);
const [eventTypes, setEventTypes] = useState<RuleExecutionEventType[]>([]);
const state = useMemo(() => ({ logLevels, eventTypes }), [logLevels, eventTypes]);
return useMemo(
() => ({ state, setLogLevels, setEventTypes }),
[state, setLogLevels, setEventTypes]
);
};

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FETCH_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleMonitoring.executionResultsTable.fetchErrorDescription',
{
defaultMessage: 'Failed to fetch rule execution results',
}
);

View file

@ -5,21 +5,20 @@
* 2.0.
*/
jest.mock('./api');
jest.mock('../../../../common/lib/kibana');
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, cleanup } from '@testing-library/react-hooks';
import { useRuleExecutionEvents } from './use_rule_execution_events';
import * as api from './api';
import { useExecutionResults } from './use_execution_results';
import { useToasts } from '../../../../common/lib/kibana';
import { api } from '../../api';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../api');
const SOME_RULE_ID = 'some-rule-id';
describe('useRuleExecutionEvents', () => {
describe('useExecutionResults', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@ -46,7 +45,7 @@ describe('useRuleExecutionEvents', () => {
const render = () =>
renderHook(
() =>
useRuleExecutionEvents({
useExecutionResults({
ruleId: SOME_RULE_ID,
start: 'now-30',
end: 'now',
@ -58,15 +57,15 @@ describe('useRuleExecutionEvents', () => {
}
);
it('calls the API via fetchRuleExecutionEvents', async () => {
const fetchRuleExecutionEvents = jest.spyOn(api, 'fetchRuleExecutionEvents');
it('calls the API via fetchRuleExecutionResults', async () => {
const fetchRuleExecutionResults = jest.spyOn(api, 'fetchRuleExecutionResults');
const { waitForNextUpdate } = render();
await waitForNextUpdate();
expect(fetchRuleExecutionEvents).toHaveBeenCalledTimes(1);
expect(fetchRuleExecutionEvents).toHaveBeenLastCalledWith(
expect(fetchRuleExecutionResults).toHaveBeenCalledTimes(1);
expect(fetchRuleExecutionResults).toHaveBeenLastCalledWith(
expect.objectContaining({ ruleId: SOME_RULE_ID })
);
});
@ -118,7 +117,7 @@ describe('useRuleExecutionEvents', () => {
it('handles exceptions from the API', async () => {
const exception = new Error('Boom!');
jest.spyOn(api, 'fetchRuleExecutionEvents').mockRejectedValue(exception);
jest.spyOn(api, 'fetchRuleExecutionResults').mockRejectedValue(exception);
const { result, waitForNextUpdate } = render();
@ -139,7 +138,7 @@ describe('useRuleExecutionEvents', () => {
// And shows a toast with the caught exception
expect(useToasts().addError).toHaveBeenCalledTimes(1);
expect(useToasts().addError).toHaveBeenCalledWith(exception, {
title: 'Failed to fetch rule execution events',
title: 'Failed to fetch rule execution results',
});
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { useQuery } from 'react-query';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { GetRuleExecutionResultsResponse } from '../../../../../common/detection_engine/rule_monitoring';
import type { FetchRuleExecutionResultsArgs } from '../../api';
import { api } from '../../api';
import * as i18n from './translations';
export type UseExecutionResultsArgs = Omit<FetchRuleExecutionResultsArgs, 'signal'>;
export const useExecutionResults = (args: UseExecutionResultsArgs) => {
const { addError } = useAppToasts();
return useQuery<GetRuleExecutionResultsResponse>(
['detectionEngine', 'ruleMonitoring', 'executionResults', args],
({ signal }) => {
return api.fetchRuleExecutionResults({ ...args, signal });
},
{
keepPreviousData: true,
onError: (e) => {
addError(e, { title: i18n.FETCH_ERROR });
},
}
);
};

View file

@ -0,0 +1,15 @@
/*
* 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 * from './api';
export * from './components/basic/filters/execution_status_filter';
export * from './components/basic/indicators/execution_status_indicator';
export * from './components/execution_events_table/execution_events_table';
export * from './components/execution_results_table/use_execution_results';
export * from './logic/execution_settings/use_execution_settings';

View file

@ -0,0 +1,50 @@
/*
* 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 { useMemo } from 'react';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useUiSetting$ } from '../../../../common/lib/kibana';
import {
EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING,
EXTENDED_RULE_EXECUTION_LOGGING_MIN_LEVEL_SETTING,
} from '../../../../../common/constants';
import type { RuleExecutionSettings } from '../../../../../common/detection_engine/rule_monitoring';
import { LogLevelSetting } from '../../../../../common/detection_engine/rule_monitoring';
export const useRuleExecutionSettings = (): RuleExecutionSettings => {
const featureFlagEnabled = useIsExperimentalFeatureEnabled('extendedRuleExecutionLoggingEnabled');
const advancedSettingEnabled = useAdvancedSettingSafely<boolean>(
EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING,
false
);
const advancedSettingMinLevel = useAdvancedSettingSafely<LogLevelSetting>(
EXTENDED_RULE_EXECUTION_LOGGING_MIN_LEVEL_SETTING,
LogLevelSetting.off
);
return useMemo<RuleExecutionSettings>(() => {
return {
extendedLogging: {
isEnabled: featureFlagEnabled && advancedSettingEnabled,
minLevel: advancedSettingMinLevel,
},
};
}, [featureFlagEnabled, advancedSettingEnabled, advancedSettingMinLevel]);
};
const useAdvancedSettingSafely = <T>(key: string, defaultValue: T): T => {
try {
const [value] = useUiSetting$<T>(key);
return value;
} catch (e) {
// It throws when the setting is not registered (when featureFlagEnabled === false).
return defaultValue;
}
};

View file

@ -0,0 +1,8 @@
/*
* 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 * from './api/__mocks__';

View file

@ -8,7 +8,7 @@
import React from 'react';
import { EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { getEmptyTagValue } from '../../../../common/components/empty_value';

View file

@ -8,7 +8,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import { RuleStatusBadge } from './rule_status_badge';
describe('RuleStatusBadge', () => {

View file

@ -11,7 +11,7 @@ import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { getCapitalizedStatusText, getStatusColor } from './utils';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
interface RuleStatusBadgeProps {
status: RuleExecutionStatus | null | undefined;

View file

@ -8,7 +8,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import { RuleStatusFailedCallOut } from './rule_status_failed_callout';
jest.mock('../../../../common/lib/kibana');

View file

@ -9,7 +9,7 @@ import React from 'react';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
import * as i18n from './translations';

View file

@ -8,7 +8,7 @@
import type { IconColor } from '@elastic/eui';
import { capitalize } from 'lodash';
import { assertUnreachable } from '../../../../../common/utility_types';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common';
import { RuleExecutionStatus } from '../../../../../common/detection_engine/rule_monitoring';
export const getStatusText = (value: RuleExecutionStatus | null | undefined): string | null => {
if (value == null) {

View file

@ -6,7 +6,6 @@
*/
import type {
GetAggregateRuleExecutionEventsResponse,
GetInstalledIntegrationsResponse,
RulesSchema,
} from '../../../../../../common/detection_engine/schemas/response';
@ -60,49 +59,6 @@ export const fetchRuleById = jest.fn(
export const fetchRules = async (_: FetchRulesProps): Promise<FetchRulesResponse> =>
Promise.resolve(rulesMock);
export const fetchRuleExecutionEvents = async ({
ruleId,
start,
end,
filters,
signal,
}: {
ruleId: string;
start: string;
end: string;
filters?: string;
signal?: AbortSignal;
}): Promise<GetAggregateRuleExecutionEventsResponse> => {
return Promise.resolve({
events: [
{
duration_ms: 3866,
es_search_duration_ms: 1236,
execution_uuid: '88d15095-7937-462c-8f21-9763e1387cad',
gap_duration_s: 0,
indexing_duration_ms: 95,
message:
"rule executed: siem.queryRule:fb1fc150-a292-11ec-a2cf-c1b28b0392b0: 'Lots of Execution Events'",
num_active_alerts: 0,
num_errored_actions: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_succeeded_actions: 1,
num_triggered_actions: 1,
schedule_delay_ms: -127535,
search_duration_ms: 1255,
security_message: 'succeeded',
security_status: 'succeeded',
status: 'success',
timed_out: false,
timestamp: '2022-03-13T06:04:05.838Z',
total_search_duration_ms: 0,
},
],
total: 1,
});
};
export const fetchTags = async ({ signal }: { signal: AbortSignal }): Promise<string[]> =>
Promise.resolve(['elastic', 'love', 'quality', 'code']);

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { buildEsQuery } from '@kbn/es-query';
import { KibanaServices } from '../../../../common/lib/kibana';
import {
createRule,
updateRule,
@ -15,7 +17,6 @@ import {
createPrepackagedRules,
importRules,
exportRules,
fetchRuleExecutionEvents,
fetchTags,
getPrePackagedRulesStatus,
previewRule,
@ -27,7 +28,7 @@ import {
} from '../../../../../common/detection_engine/schemas/request/rule_schemas.mock';
import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock';
import { rulesMock } from './mock';
import { buildEsQuery } from '@kbn/es-query';
const abortCtrl = new AbortController();
const mockKibanaServices = KibanaServices.get as jest.Mock;
jest.mock('../../../../common/lib/kibana');
@ -617,56 +618,6 @@ describe('Detections Rules API', () => {
});
});
describe('fetchRuleExecutionEvents', () => {
const responseMock = { events: [] };
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(responseMock);
});
test('calls API with correct parameters', async () => {
await fetchRuleExecutionEvents({
ruleId: '42',
start: '2001-01-01T17:00:00.000Z',
end: '2001-01-02T17:00:00.000Z',
queryText: '',
statusFilters: [],
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith(
'/internal/detection_engine/rules/42/execution/events',
{
method: 'GET',
query: {
end: '2001-01-02T17:00:00.000Z',
page: undefined,
per_page: undefined,
query_text: '',
sort_field: undefined,
sort_order: undefined,
start: '2001-01-01T17:00:00.000Z',
status_filters: '',
},
signal: abortCtrl.signal,
}
);
});
test('returns API response as is', async () => {
const response = await fetchRuleExecutionEvents({
ruleId: '42',
start: 'now-30',
end: 'now',
queryText: '',
statusFilters: [],
signal: abortCtrl.signal,
});
expect(response).toEqual(responseMock);
});
});
describe('fetchTags', () => {
beforeEach(() => {
fetchMock.mockClear();

View file

@ -5,9 +5,7 @@
* 2.0.
*/
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { camelCase } from 'lodash';
import dateMath from '@kbn/datemath';
import type { HttpStart } from '@kbn/core/public';
import {
@ -17,23 +15,17 @@ import {
DETECTION_ENGINE_TAGS_URL,
DETECTION_ENGINE_RULES_BULK_ACTION,
DETECTION_ENGINE_RULES_PREVIEW,
detectionEngineRuleExecutionEventsUrl,
DETECTION_ENGINE_INSTALLED_INTEGRATIONS_URL,
} from '../../../../../common/constants';
import type {
AggregateRuleExecutionEvent,
BulkAction,
RuleExecutionStatus,
} from '../../../../../common/detection_engine/schemas/common';
import type { BulkAction } from '../../../../../common/detection_engine/schemas/common';
import type {
FullResponseSchema,
PreviewResponse,
} from '../../../../../common/detection_engine/schemas/request';
import type {
RulesSchema,
GetAggregateRuleExecutionEventsResponse,
GetInstalledIntegrationsResponse,
} from '../../../../../common/detection_engine/schemas/response';
import type { GetInstalledIntegrationsResponse } from '../../../../../common/detection_engine/schemas/response/get_installed_integrations_response_schema';
import type {
UpdateRulesProps,
@ -321,64 +313,6 @@ export const exportRules = async ({
});
};
/**
* Fetch rule execution events (e.g. status changes) from Event Log.
*
* @param ruleId Saved Object ID of the rule (`rule.id`, not static `rule.rule_id`)
* @param start Start daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now-30`)
* @param end End daterange either in UTC ISO8601 or as datemath string (e.g. `2021-12-29T02:44:41.653Z` or `now/w`)
* @param queryText search string in querystring format (e.g. `event.duration > 1000 OR kibana.alert.rule.execution.metrics.execution_gap_duration_s > 100`)
* @param statusFilters RuleExecutionStatus[] array of `statusFilters` (e.g. `succeeded,failed,partial failure`)
* @param page current page to fetch
* @param perPage number of results to fetch per page
* @param sortField keyof AggregateRuleExecutionEvent field to sort by
* @param sortOrder SortOrder what order to sort by (e.g. `asc` or `desc`)
* @param signal AbortSignal Optional signal for cancelling the request
*
* @throws An error if response is not OK
*/
export const fetchRuleExecutionEvents = async ({
ruleId,
start,
end,
queryText,
statusFilters,
page,
perPage,
sortField,
sortOrder,
signal,
}: {
ruleId: string;
start: string;
end: string;
queryText?: string;
statusFilters?: RuleExecutionStatus[];
page?: number;
perPage?: number;
sortField?: keyof AggregateRuleExecutionEvent;
sortOrder?: SortOrder;
signal?: AbortSignal;
}): Promise<GetAggregateRuleExecutionEventsResponse> => {
const url = detectionEngineRuleExecutionEventsUrl(ruleId);
const startDate = dateMath.parse(start);
const endDate = dateMath.parse(end, { roundUp: true });
return KibanaServices.get().http.fetch<GetAggregateRuleExecutionEventsResponse>(url, {
method: 'GET',
query: {
start: startDate?.utc().toISOString(),
end: endDate?.utc().toISOString(),
query_text: queryText,
status_filters: statusFilters?.sort()?.join(','),
page,
per_page: perPage,
sort_field: sortField,
sort_order: sortOrder,
},
signal,
});
};
/**
* Fetch all unique Tags used by Rules
*

View file

@ -11,4 +11,3 @@ export * from './use_create_rule';
export * from './types';
export * from './use_rule';
export * from './use_pre_packaged_rules';
export * from './use_rule_execution_events';

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { GetAggregateRuleExecutionEventsResponse } from '../../../../../common/detection_engine/schemas/response';
import type { FetchRulesResponse, Rule } from './types';
export const savedRuleMock: Rule = {
@ -136,170 +135,3 @@ export const rulesMock: FetchRulesResponse = {
},
],
};
export const ruleExecutionEventsMock: GetAggregateRuleExecutionEventsResponse = {
events: [
{
execution_uuid: 'dc45a63c-4872-4964-a2d0-bddd8b2e634d',
timestamp: '2022-04-28T21:19:08.047Z',
duration_ms: 3,
status: 'failure',
message: 'siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: execution failed',
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2169,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 0,
gap_duration_s: 0,
security_status: 'failed',
security_message: 'Rule failed to execute because rule ran after it was disabled.',
},
{
execution_uuid: '0fde9271-05d0-4bfb-8ff8-815756d28350',
timestamp: '2022-04-28T21:19:04.973Z',
duration_ms: 1446,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2089,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 2,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '5daaa259-ded8-4a52-853e-1e7652d325d5',
timestamp: '2022-04-28T21:19:01.976Z',
duration_ms: 1395,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 1,
schedule_delay_ms: 2637,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 3,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: 'c7223e1c-4264-4a27-8697-0d720243fafc',
timestamp: '2022-04-28T21:18:58.431Z',
duration_ms: 1815,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 1,
schedule_delay_ms: -255429,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 3,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '1f6ba0c1-cc36-4f45-b919-7790b8a8d670',
timestamp: '2022-04-28T21:18:13.954Z',
duration_ms: 2055,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 0,
schedule_delay_ms: 2027,
timed_out: false,
indexing_duration_ms: 0,
search_duration_ms: 0,
gap_duration_s: 0,
security_status: 'partial failure',
security_message:
'Check privileges failed to execute ResponseError: index_not_found_exception: [index_not_found_exception] Reason: no such index [yup] name: "Click here for hot fresh alerts!" id: "a6e61cf0-c737-11ec-9e32-e14913ffdd2d" rule id: "34946b12-88d1-49ef-82b7-9cad45972030" execution id: "1f6ba0c1-cc36-4f45-b919-7790b8a8d670" space ID: "default"',
},
{
execution_uuid: 'b0f65d64-b229-432b-9d39-f4385a7f9368',
timestamp: '2022-04-28T21:15:43.086Z',
duration_ms: 1205,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 672,
schedule_delay_ms: 3086,
timed_out: false,
indexing_duration_ms: 140,
search_duration_ms: 684,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
{
execution_uuid: '7bfd25b9-c0d8-44b1-982c-485169466a8e',
timestamp: '2022-04-28T21:10:40.135Z',
duration_ms: 6321,
status: 'success',
message:
"rule executed: siem.queryRule:a6e61cf0-c737-11ec-9e32-e14913ffdd2d: 'Click here for hot fresh alerts!'",
num_active_alerts: 0,
num_new_alerts: 0,
num_recovered_alerts: 0,
num_triggered_actions: 0,
num_succeeded_actions: 0,
num_errored_actions: 0,
total_search_duration_ms: 0,
es_search_duration_ms: 930,
schedule_delay_ms: 1222,
timed_out: false,
indexing_duration_ms: 2103,
search_duration_ms: 946,
gap_duration_s: 0,
security_status: 'succeeded',
security_message: 'succeeded',
},
],
total: 7,
};

View file

@ -109,10 +109,3 @@ export const RELOAD_MISSING_PREPACKAGED_RULES_AND_TIMELINES = (
'Install {missingRules} Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} and {missingTimelines} Elastic prebuilt {missingTimelines, plural, =1 {timeline} other {timelines}} ',
}
);
export const RULE_EXECUTION_EVENTS_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.ruleExecutionEventsFetchFailDescription',
{
defaultMessage: 'Failed to fetch rule execution events',
}
);

View file

@ -22,6 +22,9 @@ import {
severity_mapping,
severity,
} from '@kbn/securitysolution-io-ts-alerting-types';
import { RuleExecutionSummary } from '../../../../../common/detection_engine/rule_monitoring';
import type {
SortOrder,
BulkAction,
@ -41,7 +44,6 @@ import {
event_category_override,
tiebreaker_field,
threshold,
ruleExecutionSummary,
RelatedIntegrationArray,
RequiredFieldArray,
SetupGuide,
@ -169,7 +171,7 @@ export const RuleSchema = t.intersection([
exceptions_list: listArray,
uuid: t.string,
version: t.number,
execution_summary: ruleExecutionSummary,
execution_summary: RuleExecutionSummary,
}),
]);

View file

@ -1,80 +0,0 @@
/*
* 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 { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { useQuery } from 'react-query';
import type {
AggregateRuleExecutionEvent,
RuleExecutionStatus,
} from '../../../../../common/detection_engine/schemas/common';
import type { GetAggregateRuleExecutionEventsResponse } from '../../../../../common/detection_engine/schemas/response';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { fetchRuleExecutionEvents } from './api';
import * as i18n from './translations';
export interface UseRuleExecutionEventsArgs {
ruleId: string;
start: string;
end: string;
queryText?: string;
statusFilters?: RuleExecutionStatus[];
page?: number;
perPage?: number;
sortField?: keyof AggregateRuleExecutionEvent;
sortOrder?: SortOrder;
}
export const useRuleExecutionEvents = ({
ruleId,
start,
end,
queryText,
statusFilters,
page,
perPage,
sortField,
sortOrder,
}: UseRuleExecutionEventsArgs) => {
const { addError } = useAppToasts();
return useQuery<GetAggregateRuleExecutionEventsResponse>(
[
'ruleExecutionEvents',
{
ruleId,
start,
end,
queryText,
statusFilters,
page,
perPage,
sortField,
sortOrder,
},
],
async ({ signal }) => {
return fetchRuleExecutionEvents({
ruleId,
start,
end,
queryText,
statusFilters,
page,
perPage,
sortField,
sortOrder,
signal,
});
},
{
keepPreviousData: true,
onError: (e) => {
addError(e, { title: i18n.RULE_EXECUTION_EVENTS_FETCH_FAILURE });
},
}
);
};

View file

@ -39,7 +39,7 @@ import { RuleStatusBadge } from '../../../../components/rules/rule_execution_sta
import type {
DurationMetric,
RuleExecutionSummary,
} from '../../../../../../common/detection_engine/schemas/common';
} from '../../../../../../common/detection_engine/rule_monitoring';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction';
import { useInvalidateRules } from '../../../../containers/detection_engine/rules/use_find_rules_query';

View file

@ -10,7 +10,7 @@ import type { RuleDetailsContextType } from '../rule_details_context';
export const useRuleDetailsContextMock = {
create: (): jest.Mocked<RuleDetailsContextType> => ({
executionLogs: {
executionResults: {
state: {
superDatePicker: {
recentlyUsedRanges: [],

Some files were not shown because too many files have changed in this diff Show more