mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Security Solution] Handle negative lookback in rule upgrade flyout (#204317)
**Fixes: https://github.com/elastic/kibana/issues/202715** **Fixes: https://github.com/elastic/kibana/issues/204714** ## Summary This PR makes inconsistent/wrong rule's look-back duration prominent for a user. It falls back to a default 1 minute value in rule upgrade workflow. ## Details ### Negative/wrong `lookback` problem There is a difference between rule schedule value in a saved object and value represented to users - Saved object (and rule management API) has `interval`, `from` and `to` fields representing rule schedule. `interval` shows how often a rule runs in task runner. `from` and `to` stored in date math format like `now-10m` represent a date time range used to fetch source events. Task manager strives to run rules exactly every `interval` but it's not always possible due to multiple reasons like system load and various delays. To avoid any gaps to appear `from` point in time usually stands earlier than current time minus `interval`, for example `interval` is `10 minutes` and `from` is `now-12m` meaning rule will analyze events starting from 12 minutes old. `to` represents the latest point in time source events will be analyzed. - Diffable rule and UI represent rule schedule as `interval` and `lookback`. Where `interval` is the same as above and `lookback` and a time duration before current time minus `interval`. For example `interval` is `10 minutes` and lookback is `2 minutes` it means a rule will analyzing events starting with 12 minutes old until the current moment in time. Literally `interval`, `from` and `to` mean a rule runs every `interval` and analyzes events starting from `from` until `to`. Technically `from` and `to` may not have any correlation with `interval`, for example a rule may analyze one year old events. While it's reasonable for manual rule runs and gap remediation the same approach doesn't work well for usual rule schedule. Transformation between `interval`/`from`/`to` and `interval`/`lookback` works only when `to` is equal the current moment in time i.e. `now`. Rule management APIs allow to set any `from` and `to` values resulting in inconsistent rule schedule. Transformed `interval`/`lookback` value won't represent real time interval used to fetch source events for analysis. On top of that negative `lookback` value may puzzle users on the meaning of the negative sign. ### Prebuilt rules with `interval`/`from`/`to` resulting in negative `lookback` Some prebuilt rules have such `interval`, `from` and `to` field values thatnegative `lookback` is expected, for example `Multiple Okta Sessions Detected for a Single User`. It runs every `60 minutes` but has `from` field set to `now-30m` and `to` equals `now`. In the end we have `lookback` equals `to` - `from` - `interval` = `30 minutes` - `60 minutes` = `-30 minutes`. Our UI doesn't handle negative `lookback` values. It simply discards a negative sign and substitutes the rest for editing. In the case above `30 minutes` will be suggested for editing. Saving the form will result in changing `from` to `now-90m` <img width="1712" alt="image" src="https://github.com/user-attachments/assets/05519743-9562-4874-8a73-5596eeccacf2" /> ### Changes in this PR This PR mitigates rule schedule inconsistencies caused by `to` fields not using the current point in time i.e. `now`. The following was done - `DiffableRule`'s `rule_schedule` was changed to have `interval`, `from` and `to` fields instead of `interval` and `lookback` - `_perform` rule upgrade API endpoint was adapted to the new `DIffableRule`'s `rule_schedule` - Rule upgrade flyout calculates and shows `interval` and `lookback` in Diff View, readonly view and field form when `lookback` is non-negative and `to` equals `now` - Rule upgrade flyout shows `interval`, `from` and `to` in Diff View, readonly view and field form when `to` isn't equal `now` or calculated `lookback` is negative - Rule upgrade flyout shows a warning when `to` isn't equal `now` or calculated `lookback` is negative - Rule upgrade flyout's JSON Diff shows `interval` and `lookback` when `lookback` is non-negative and `to` equals `now` and shows `interval`, `from` and `to` in any other case - Rule details page shows `interval`, `from` and `to` in Diff View, readonly view and field form when `to` isn't equal `now` or calculated `lookback` is negative - `maxValue` was added to `ScheduleItemField` to have an ability to restrict input at reasonable values ## Screenshots - Rule upgrade workflow (negative look-back) <img width="2558" alt="Screenshot 2025-01-02 at 13 16 59" src="https://github.com/user-attachments/assets/b8bf727f-11ca-424f-892b-b024ba7f847a" /> <img width="2553" alt="Screenshot 2025-01-02 at 13 17 20" src="https://github.com/user-attachments/assets/9f751ea4-0ce0-4a23-a3b7-0a16494d957e" /> <img width="2558" alt="Screenshot 2025-01-02 at 13 18 24" src="https://github.com/user-attachments/assets/6908ab02-4011-4a6e-85ce-e60d5eac7993" /> - Rule upgrade workflow (positive look-back) <img width="2555" alt="Screenshot 2025-01-02 at 13 19 12" src="https://github.com/user-attachments/assets/06208210-c6cd-4842-8aef-6ade5d13bd36" /> <img width="2558" alt="Screenshot 2025-01-02 at 13 25 31" src="https://github.com/user-attachments/assets/aed38bb0-ccfb-479a-bb3b-e5442c518e63" /> - JSON view <img width="2559" alt="Screenshot 2025-01-02 at 13 31 37" src="https://github.com/user-attachments/assets/07575a81-676f-418e-8b98-48eefe11ab00" /> - Rule details page <img width="2555" alt="Screenshot 2025-01-02 at 13 13 16" src="https://github.com/user-attachments/assets/e977b752-9d50-4049-917a-af2e8e3f0dfe" /> <img width="2558" alt="Screenshot 2025-01-02 at 13 14 10" src="https://github.com/user-attachments/assets/06d6f477-5730-48ca-a240-b5e7592bf173" /> ## How to test? - Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled - Allow internal APIs via adding `server.restrictInternalApis: false` to `kibana.dev.yaml` - Clear Elasticsearch data - Run Elasticsearch and Kibana locally (do not open Kibana in a web browser) - Install an outdated version of the `security_detection_engine` Fleet package ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.14.1 ``` - Install prebuilt rules ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 1" -d '{"mode":"ALL_RULES"}' http://localhost:5601/kbn/internal/detection_engine/prebuilt_rules/installation/_perform ``` - Set "inconsistent" rule schedule for `Suspicious File Creation via Kworker` rule by running a query below ```bash curl -X PATCH --user elastic:changeme -H "Content-Type: application/json" -H "elastic-api-version: 2023-10-31" -H "kbn-xsrf: 123" -d '{"rule_id":"ae343298-97bc-47bc-9ea2-5f2ad831c16e","interval":"10m","from":"now-5m","to":"now-2m"}' http://localhost:5601/kbn/api/detection_engine/rules ``` - Open rule upgrade flyout for `Suspicious File Creation via Kworker` rule --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
a0bdc19684
commit
30bb71a516
57 changed files with 1843 additions and 619 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -984,7 +984,7 @@ x-pack/solutions/security/packages/kbn-securitysolution-list-hooks @elastic/secu
|
|||
x-pack/solutions/security/packages/kbn-securitysolution-list-utils @elastic/security-detection-engine
|
||||
x-pack/solutions/security/packages/kbn-securitysolution-lists-common @elastic/security-detection-engine
|
||||
x-pack/solutions/security/packages/kbn-securitysolution-t-grid @elastic/security-detection-engine
|
||||
x-pack/solutions/security/packages/kbn-securitysolution-utils @elastic/security-detection-engine
|
||||
x-pack/solutions/security/packages/kbn-securitysolution-utils @elastic/security-detection-engine @elastic/security-detection-rule-management
|
||||
x-pack/solutions/security/packages/navigation @elastic/security-threat-hunting-explore
|
||||
x-pack/solutions/security/packages/side_nav @elastic/security-threat-hunting-explore
|
||||
x-pack/solutions/security/packages/storybook/config @elastic/security-threat-hunting-explore
|
||||
|
|
|
@ -36637,7 +36637,6 @@
|
|||
"xpack.securitySolution.detectionEngine.ruleDetails.enableRuleLabel": "Activer",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.endpointExceptionsTab": "Exceptions de point de terminaison",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.falsePositivesFieldLabel": "Exemples de faux positifs",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.fromFieldLabel": "Temps de récupération supplémentaire",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.historyWindowSizeFieldLabel": "Taille de la fenêtre d’historique",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.indexFieldLabel": "Modèles d'indexation",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.installAndEnableButtonLabel": "Installer et activer",
|
||||
|
|
|
@ -36496,7 +36496,6 @@
|
|||
"xpack.securitySolution.detectionEngine.ruleDetails.enableRuleLabel": "有効にする",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.endpointExceptionsTab": "エンドポイント例外",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.falsePositivesFieldLabel": "誤検出の例",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.fromFieldLabel": "追加のルックバック時間",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.historyWindowSizeFieldLabel": "履歴ウィンドウサイズ",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.indexFieldLabel": "インデックスパターン",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.installAndEnableButtonLabel": "インストールして有効化",
|
||||
|
|
|
@ -35954,7 +35954,6 @@
|
|||
"xpack.securitySolution.detectionEngine.ruleDetails.enableRuleLabel": "启用",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.endpointExceptionsTab": "终端例外",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.falsePositivesFieldLabel": "误报示例",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.fromFieldLabel": "更多回查时间",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.historyWindowSizeFieldLabel": "历史记录窗口大小",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.indexFieldLabel": "索引模式",
|
||||
"xpack.securitySolution.detectionEngine.ruleDetails.installAndEnableButtonLabel": "安装并启用",
|
||||
|
|
|
@ -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 './src/date_math';
|
|
@ -1,9 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/securitysolution-utils",
|
||||
"owner": [
|
||||
"@elastic/security-detection-engine"
|
||||
],
|
||||
"owner": ["@elastic/security-detection-engine", "@elastic/security-detection-rule-management"],
|
||||
"group": "security",
|
||||
"visibility": "private"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 { calcDateMathDiff } from './calc_date_math_diff';
|
||||
|
||||
describe('calcDateMathDiff', () => {
|
||||
it.each([
|
||||
['now-62s', 'now-1m', 2000],
|
||||
['now-122s', 'now-1m', 62000],
|
||||
['now-660s', 'now-5m', 360000],
|
||||
['now-6600s', 'now-5m', 6300000],
|
||||
['now-7500s', 'now-5m', 7200000],
|
||||
['now-1m', 'now-62s', -2000],
|
||||
['now-1m', 'now-122s', -62000],
|
||||
['now-5m', 'now-660s', -360000],
|
||||
['now-5m', 'now-6600s', -6300000],
|
||||
['now-5m', 'now-7500s', -7200000],
|
||||
['now-1s', 'now-1s', 0],
|
||||
['now-1m', 'now-1m', 0],
|
||||
['now-1h', 'now-1h', 0],
|
||||
['now-1d', 'now-1d', 0],
|
||||
])('calculates milliseconds diff between "%s" and "%s"', (start, end, expected) => {
|
||||
const result = calcDateMathDiff(start, end);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('returns "undefined" when start is invalid date math', () => {
|
||||
const result = calcDateMathDiff('invalid', 'now-5m');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns "undefined" when end is invalid date math', () => {
|
||||
const result = calcDateMathDiff('now-300s', 'invalid');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -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 dateMath from '@kbn/datemath';
|
||||
|
||||
/**
|
||||
* Calculates difference between date math expressions in milliseconds.
|
||||
*/
|
||||
export function calcDateMathDiff(start: string, end: string): number | undefined {
|
||||
const now = new Date();
|
||||
const startMoment = dateMath.parse(start, { forceNow: now });
|
||||
const endMoment = dateMath.parse(end, { forceNow: now });
|
||||
|
||||
if (!startMoment || !endMoment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = endMoment.diff(startMoment, 'ms');
|
||||
|
||||
return !isNaN(result) ? result : undefined;
|
||||
}
|
|
@ -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 './calc_date_math_diff';
|
||||
export * from './normalize_date_math';
|
|
@ -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 { normalizeDateMath } from './normalize_date_math';
|
||||
|
||||
describe('normalizeDateMath', () => {
|
||||
it.each([
|
||||
['now-60s', 'now-1m'],
|
||||
['now-60m', 'now-1h'],
|
||||
['now-24h', 'now-1d'],
|
||||
['now+60s', 'now+1m'],
|
||||
['now+60m', 'now+1h'],
|
||||
['now+24h', 'now+1d'],
|
||||
])('normalizes %s', (sourceDateMath, normalizedDateMath) => {
|
||||
const result = normalizeDateMath(sourceDateMath);
|
||||
|
||||
expect(result).toBe(normalizedDateMath);
|
||||
});
|
||||
|
||||
it.each([['now'], ['now-invalid'], ['invalid']])('returns %s non-normalized', (dateMath) => {
|
||||
const result = normalizeDateMath(dateMath);
|
||||
|
||||
expect(result).toBe(dateMath);
|
||||
});
|
||||
});
|
|
@ -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 { TimeDuration } from '../time_duration/time_duration';
|
||||
import { calcDateMathDiff } from './calc_date_math_diff';
|
||||
|
||||
/**
|
||||
* Normalizes date math
|
||||
*/
|
||||
export function normalizeDateMath(input: string): string {
|
||||
try {
|
||||
const ms = calcDateMathDiff('now', input);
|
||||
|
||||
if (ms === undefined || (ms > -1000 && ms < 1000)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (ms === 0) {
|
||||
return 'now';
|
||||
}
|
||||
|
||||
const offset = TimeDuration.fromMilliseconds(ms);
|
||||
|
||||
return offset.value < 0 ? `now${offset}` : `now+${offset}`;
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* 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 { TimeDuration } from './time_duration';
|
||||
|
||||
describe('TimeDuration', () => {
|
||||
describe('fromMilliseconds', () => {
|
||||
it.each([
|
||||
[5000, new TimeDuration(5, 's')],
|
||||
[600000, new TimeDuration(10, 'm')],
|
||||
[25200000, new TimeDuration(7, 'h')],
|
||||
[777600000, new TimeDuration(9, 'd')],
|
||||
[-3000, new TimeDuration(-3, 's')],
|
||||
[-300000, new TimeDuration(-5, 'm')],
|
||||
[-18000000, new TimeDuration(-5, 'h')],
|
||||
[-604800000, new TimeDuration(-7, 'd')],
|
||||
])('parses "%s"', (ms, expectedTimeDuration) => {
|
||||
const result = TimeDuration.fromMilliseconds(ms);
|
||||
|
||||
expect(result).toEqual(expectedTimeDuration);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it.each([
|
||||
['5s', new TimeDuration(5, 's')],
|
||||
['10m', new TimeDuration(10, 'm')],
|
||||
['7h', new TimeDuration(7, 'h')],
|
||||
['9d', new TimeDuration(9, 'd')],
|
||||
['+5s', new TimeDuration(5, 's')],
|
||||
['+10m', new TimeDuration(10, 'm')],
|
||||
['+7h', new TimeDuration(7, 'h')],
|
||||
['+9d', new TimeDuration(9, 'd')],
|
||||
['-3s', new TimeDuration(-3, 's')],
|
||||
['-5m', new TimeDuration(-5, 'm')],
|
||||
['-5h', new TimeDuration(-5, 'h')],
|
||||
['-7d', new TimeDuration(-7, 'd')],
|
||||
['0s', new TimeDuration(0, 's')],
|
||||
['0m', new TimeDuration(0, 's')],
|
||||
['0h', new TimeDuration(0, 's')],
|
||||
['0d', new TimeDuration(0, 's')],
|
||||
['+0s', new TimeDuration(0, 's')],
|
||||
['+0m', new TimeDuration(0, 's')],
|
||||
['+0h', new TimeDuration(0, 's')],
|
||||
['+0d', new TimeDuration(0, 's')],
|
||||
['-0s', new TimeDuration(0, 's')],
|
||||
['-0m', new TimeDuration(0, 's')],
|
||||
['-0h', new TimeDuration(0, 's')],
|
||||
['-0d', new TimeDuration(0, 's')],
|
||||
])('parses "%s"', (duration, expectedTimeDuration) => {
|
||||
const result = TimeDuration.parse(duration);
|
||||
|
||||
expect(result).toEqual(expectedTimeDuration);
|
||||
});
|
||||
|
||||
it('does NOT trim leading spaces', () => {
|
||||
const result = TimeDuration.parse(' 6m');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT trim trailing spaces', () => {
|
||||
const result = TimeDuration.parse('8h ');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([[''], [' '], ['s'], ['invalid'], ['3ss'], ['m4s'], ['78']])(
|
||||
'returns "undefined" when tries to parse invalid duration "%s"',
|
||||
(invalidDuration) => {
|
||||
const result = TimeDuration.parse(invalidDuration);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['1S'], ['2M'], ['3H'], ['4D'], ['5Y'], ['7nanos'], ['8ms']])(
|
||||
'returns "undefined" when tries to parse unsupported duration units "%s"',
|
||||
(invalidDuration) => {
|
||||
const result = TimeDuration.parse(invalidDuration);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('toMilliseconds', () => {
|
||||
it.each([
|
||||
[new TimeDuration(5, 's'), 5000],
|
||||
[new TimeDuration(10, 'm'), 600000],
|
||||
[new TimeDuration(7, 'h'), 25200000],
|
||||
[new TimeDuration(9, 'd'), 777600000],
|
||||
[new TimeDuration(-3, 's'), -3000],
|
||||
[new TimeDuration(-5, 'm'), -300000],
|
||||
[new TimeDuration(-5, 'h'), -18000000],
|
||||
[new TimeDuration(-7, 'd'), -604800000],
|
||||
])('converts %j to %d milliseconds', (timeDuration, expected) => {
|
||||
const result = timeDuration.toMilliseconds();
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new TimeDuration(0, 's')],
|
||||
[new TimeDuration(0, 'm')],
|
||||
[new TimeDuration(0, 'h')],
|
||||
[new TimeDuration(0, 'd')],
|
||||
[new TimeDuration(-0, 's')],
|
||||
[new TimeDuration(-0, 'm')],
|
||||
[new TimeDuration(-0, 'h')],
|
||||
[new TimeDuration(-0, 'd')],
|
||||
])('converts %j to zero', (timeDuration) => {
|
||||
const result = timeDuration.toMilliseconds();
|
||||
|
||||
// Handle negative zero case. Jest treats 0 !== -0.
|
||||
expect(`${result}`).toBe('0');
|
||||
});
|
||||
|
||||
it.each([
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(0, '')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(0, ' ')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(0, 'invalid')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(3, 'ss')],
|
||||
])('returns "undefined" when tries to convert invalid duration %j', (invalidTimeDuration) => {
|
||||
const result = invalidTimeDuration.toMilliseconds();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(1, 'S')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(2, 'M')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(3, 'H')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(4, 'D')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(5, 'Y')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(7, 'nanos')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(8, 'ms')],
|
||||
])(
|
||||
'returns "undefined" when tries to convert unsupported duration units %j',
|
||||
(invalidTimeDuration) => {
|
||||
const result = invalidTimeDuration.toMilliseconds();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('toNormalizedTimeDuration', () => {
|
||||
it.each([
|
||||
[new TimeDuration(5, 's'), new TimeDuration(5, 's')],
|
||||
[new TimeDuration(65, 's'), new TimeDuration(65, 's')],
|
||||
[new TimeDuration(600, 's'), new TimeDuration(10, 'm')],
|
||||
[new TimeDuration(650, 's'), new TimeDuration(650, 's')],
|
||||
[new TimeDuration(90, 'm'), new TimeDuration(90, 'm')],
|
||||
[new TimeDuration(25200, 's'), new TimeDuration(7, 'h')],
|
||||
[new TimeDuration(120, 'm'), new TimeDuration(2, 'h')],
|
||||
[new TimeDuration(36, 'h'), new TimeDuration(36, 'h')],
|
||||
[new TimeDuration(777600, 's'), new TimeDuration(9, 'd')],
|
||||
[new TimeDuration(5184000, 's'), new TimeDuration(60, 'd')],
|
||||
[new TimeDuration(1440, 'm'), new TimeDuration(1, 'd')],
|
||||
[new TimeDuration(48, 'h'), new TimeDuration(2, 'd')],
|
||||
[new TimeDuration(-5, 's'), new TimeDuration(-5, 's')],
|
||||
[new TimeDuration(-65, 's'), new TimeDuration(-65, 's')],
|
||||
[new TimeDuration(-600, 's'), new TimeDuration(-10, 'm')],
|
||||
[new TimeDuration(-650, 's'), new TimeDuration(-650, 's')],
|
||||
[new TimeDuration(-90, 'm'), new TimeDuration(-90, 'm')],
|
||||
[new TimeDuration(-25200, 's'), new TimeDuration(-7, 'h')],
|
||||
[new TimeDuration(-120, 'm'), new TimeDuration(-2, 'h')],
|
||||
[new TimeDuration(-36, 'h'), new TimeDuration(-36, 'h')],
|
||||
[new TimeDuration(-777600, 's'), new TimeDuration(-9, 'd')],
|
||||
[new TimeDuration(-5184000, 's'), new TimeDuration(-60, 'd')],
|
||||
[new TimeDuration(-1440, 'm'), new TimeDuration(-1, 'd')],
|
||||
[new TimeDuration(-48, 'h'), new TimeDuration(-2, 'd')],
|
||||
])('converts %j to normalized time duration %j', (timeDuration, expected) => {
|
||||
const result = timeDuration.toNormalizedTimeDuration();
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new TimeDuration(0, 's')],
|
||||
[new TimeDuration(0, 'm')],
|
||||
[new TimeDuration(0, 'h')],
|
||||
[new TimeDuration(0, 'd')],
|
||||
])('converts %j to 0s', (timeDuration) => {
|
||||
const result = timeDuration.toNormalizedTimeDuration();
|
||||
|
||||
expect(result).toEqual(new TimeDuration(0, 's'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(1, 'S')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(2, 'M')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(3, 'H')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(4, 'D')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(5, 'Y')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(7, 'nanos')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(8, 'ms')],
|
||||
// @ts-expect-error testing invalid unit
|
||||
[new TimeDuration(0, 'invalid')],
|
||||
])('returns %j unchanged', (timeDuration) => {
|
||||
const result = timeDuration.toNormalizedTimeDuration();
|
||||
|
||||
expect(result).toEqual(timeDuration);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides a convenient way to manipulate a time duration.
|
||||
*
|
||||
* Time duration is stored as a string in Security Solution, e.g.
|
||||
* - 5s
|
||||
* - 3m
|
||||
* - 7h
|
||||
*/
|
||||
export class TimeDuration {
|
||||
/**
|
||||
* Constructs a time duration from milliseconds. The output is normalized.
|
||||
*/
|
||||
static fromMilliseconds(ms: number): TimeDuration {
|
||||
return new TimeDuration(Math.round(ms / 1000), 's').toNormalizedTimeDuration();
|
||||
}
|
||||
|
||||
/*
|
||||
* Parses a duration string and returns value and units. The output is normalized.
|
||||
* Returns `undefined` when unable to parse.
|
||||
*
|
||||
* Recognizes
|
||||
* - seconds (e.g. 2s)
|
||||
* - minutes (e.g. 5m)
|
||||
* - hours (e.g. 7h)
|
||||
* - days (e.g. 9d)
|
||||
*/
|
||||
static parse(input: string): TimeDuration | undefined {
|
||||
if (typeof input !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchArray = input.match(TIME_DURATION_REGEX);
|
||||
|
||||
if (!matchArray) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = parseInt(matchArray[1], 10);
|
||||
const unit = matchArray[2] as TimeDuration['unit'];
|
||||
|
||||
return new TimeDuration(value, unit).toNormalizedTimeDuration();
|
||||
}
|
||||
|
||||
constructor(public value: number, public unit: TimeDurationUnits) {}
|
||||
|
||||
/**
|
||||
* Convert time duration to milliseconds.
|
||||
* Supports
|
||||
* - `s` seconds, e.g. 3s, 0s, -5s
|
||||
* - `m` minutes, e.g. 10m, 0m
|
||||
* - `h` hours, e.g. 7h
|
||||
* - `d` days, e.g. 3d
|
||||
*
|
||||
* Returns `undefined` when unable to perform conversion.
|
||||
*/
|
||||
toMilliseconds(): number {
|
||||
switch (this.unit) {
|
||||
case 's':
|
||||
return this.value * 1000;
|
||||
case 'm':
|
||||
return this.value * 1000 * 60;
|
||||
case 'h':
|
||||
return this.value * 1000 * 60 * 60;
|
||||
case 'd':
|
||||
return this.value * 1000 * 60 * 60 * 24;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts time duration to the largest possible units. E.g.
|
||||
* - 60s transformed to 1m
|
||||
* - 3600s transformed to 1h
|
||||
* - 1440m transformed to 1d
|
||||
*/
|
||||
toNormalizedTimeDuration(): TimeDuration {
|
||||
const ms = this.toMilliseconds();
|
||||
|
||||
if (ms === undefined) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (ms === 0) {
|
||||
return new TimeDuration(0, 's');
|
||||
}
|
||||
|
||||
if (ms % (3600000 * 24) === 0) {
|
||||
return new TimeDuration(ms / (3600000 * 24), 'd');
|
||||
}
|
||||
|
||||
if (ms % 3600000 === 0) {
|
||||
return new TimeDuration(ms / 3600000, 'h');
|
||||
}
|
||||
|
||||
if (ms % 60000 === 0) {
|
||||
return new TimeDuration(ms / 60000, 'm');
|
||||
}
|
||||
|
||||
if (ms % 1000 === 0) {
|
||||
return new TimeDuration(ms / 1000, 's');
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.value}${this.unit}`;
|
||||
}
|
||||
}
|
||||
|
||||
const TimeDurationUnits = ['s', 'm', 'h', 'd'] as const;
|
||||
type TimeDurationUnits = (typeof TimeDurationUnits)[number];
|
||||
|
||||
const TIME_DURATION_REGEX = new RegExp(`^((?:\\-|\\+)?[0-9]+)(${TimeDurationUnits.join('|')})$`);
|
|
@ -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 './src/time_duration/time_duration';
|
|
@ -2,21 +2,15 @@
|
|||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
],
|
||||
"include": ["**/*.ts"],
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
"@kbn/esql-utils",
|
||||
"@kbn/esql-ast",
|
||||
"@kbn/esql-validation-autocomplete"
|
||||
"@kbn/esql-validation-autocomplete",
|
||||
"@kbn/datemath"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
]
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -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 { z } from '@kbn/zod';
|
||||
import { RuleIntervalFrom, RuleIntervalTo } from './common_attributes.gen';
|
||||
import { TimeDuration as TimeDurationSchema } from './time_duration';
|
||||
|
||||
export type RuleSchedule = z.infer<typeof RuleSchedule>;
|
||||
export const RuleSchedule = z.object({
|
||||
interval: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
|
||||
from: RuleIntervalFrom,
|
||||
to: RuleIntervalTo,
|
||||
});
|
||||
|
||||
/**
|
||||
* Simpler version of RuleSchedule. It's only feasible when
|
||||
* - `to` equals `now` (current moment in time)
|
||||
* - `from` is less than `now` - `interval`
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* - rule schedule: interval = 10m, from = now-15m, to = now
|
||||
* simpler rule schedule: interval = 10m, lookback = 5m
|
||||
*
|
||||
* - rule schedule: interval = 1h, from = now-120m, to = now
|
||||
* simpler rule schedule: interval = 10m, lookback = 5m
|
||||
*/
|
||||
export type SimpleRuleSchedule = z.infer<typeof SimpleRuleSchedule>;
|
||||
export const SimpleRuleSchedule = z.object({
|
||||
/**
|
||||
* Rule running interval in time duration format, e.g. `2m`, `3h`
|
||||
*/
|
||||
interval: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
|
||||
/**
|
||||
* Non-negative additional source events look-back to compensate rule execution delays
|
||||
* in time duration format, e.g. `2m`, `3h`.
|
||||
*
|
||||
* Having `interval`, `from` and `to` and can be calculated as
|
||||
*
|
||||
* lookback = now - `interval` - `from`, where `now` is the current moment in time
|
||||
*
|
||||
* In the other words rules use time range [now - interval - lookback, now]
|
||||
* to select source events for analysis.
|
||||
*/
|
||||
lookback: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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 { toSimpleRuleSchedule } from './to_simple_rule_schedule';
|
||||
|
||||
describe('toSimpleRuleSchedule', () => {
|
||||
it.each([
|
||||
[
|
||||
{ interval: '10s', from: 'now-20s', to: 'now' },
|
||||
{ interval: '10s', lookback: '10s' },
|
||||
],
|
||||
[
|
||||
{ interval: '10m', from: 'now-30m', to: 'now' },
|
||||
{ interval: '10m', lookback: '20m' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-3h', to: 'now' },
|
||||
{ interval: '1h', lookback: '2h' },
|
||||
],
|
||||
[
|
||||
{ interval: '60s', from: 'now-2m', to: 'now' },
|
||||
{ interval: '60s', lookback: '1m' },
|
||||
],
|
||||
[
|
||||
{ interval: '60s', from: 'now-2h', to: 'now' },
|
||||
{ interval: '60s', lookback: '119m' },
|
||||
],
|
||||
[
|
||||
{ interval: '60m', from: 'now-3h', to: 'now' },
|
||||
{ interval: '60m', lookback: '2h' },
|
||||
],
|
||||
[
|
||||
{ interval: '3600s', from: 'now-5h', to: 'now' },
|
||||
{ interval: '3600s', lookback: '4h' },
|
||||
],
|
||||
[
|
||||
{ interval: '1m', from: 'now-120s', to: 'now' },
|
||||
{ interval: '1m', lookback: '1m' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-7200s', to: 'now' },
|
||||
{ interval: '1h', lookback: '1h' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-120m', to: 'now' },
|
||||
{ interval: '1h', lookback: '1h' },
|
||||
],
|
||||
[
|
||||
{ interval: '90s', from: 'now-90s', to: 'now' },
|
||||
{ interval: '90s', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '30m', from: 'now-30m', to: 'now' },
|
||||
{ interval: '30m', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-1h', to: 'now' },
|
||||
{ interval: '1h', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '60s', from: 'now-1m', to: 'now' },
|
||||
{ interval: '60s', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '60m', from: 'now-1h', to: 'now' },
|
||||
{ interval: '60m', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '1m', from: 'now-60s', to: 'now' },
|
||||
{ interval: '1m', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-60m', to: 'now' },
|
||||
{ interval: '1h', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '1h', from: 'now-3600s', to: 'now' },
|
||||
{ interval: '1h', lookback: '0s' },
|
||||
],
|
||||
[
|
||||
{ interval: '0s', from: 'now', to: 'now' },
|
||||
{ interval: '0s', lookback: '0s' },
|
||||
],
|
||||
])('transforms %j to simple rule schedule', (fullRuleSchedule, expected) => {
|
||||
const result = toSimpleRuleSchedule(fullRuleSchedule);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ interval: 'invalid', from: 'now-11m', to: 'now' }],
|
||||
[{ interval: '10m', from: 'invalid', to: 'now' }],
|
||||
[{ interval: '10m', from: 'now-11m', to: 'invalid' }],
|
||||
[{ interval: '10m', from: 'now-11m', to: 'now-1m' }],
|
||||
[{ interval: '10m', from: 'now-5m', to: 'now' }],
|
||||
])('returns "undefined" for %j', (fullRuleSchedule) => {
|
||||
const result = toSimpleRuleSchedule(fullRuleSchedule);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -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 { calcDateMathDiff } from '@kbn/securitysolution-utils/date_math';
|
||||
import { TimeDuration as TimeDurationUtil } from '@kbn/securitysolution-utils/time_duration';
|
||||
import type { RuleSchedule, SimpleRuleSchedule } from './rule_schedule';
|
||||
|
||||
/**
|
||||
* Transforms RuleSchedule to SimpleRuleSchedule by replacing `from` and `to` with `lookback`.
|
||||
*
|
||||
* The transformation is only possible when `to` equals to `now` and result `lookback` is non-negative.
|
||||
*/
|
||||
export function toSimpleRuleSchedule(ruleSchedule: RuleSchedule): SimpleRuleSchedule | undefined {
|
||||
if (ruleSchedule.to !== 'now') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lookBackMs = calcDateMathDiff(ruleSchedule.from, `now-${ruleSchedule.interval}`);
|
||||
|
||||
if (lookBackMs === undefined || lookBackMs < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
interval: ruleSchedule.interval,
|
||||
lookback: TimeDurationUtil.fromMilliseconds(lookBackMs).toString(),
|
||||
};
|
||||
}
|
|
@ -23,7 +23,6 @@ import {
|
|||
TimestampOverride,
|
||||
TimestampOverrideFallbackDisabled,
|
||||
} from '../../../../model/rule_schema';
|
||||
import { TimeDuration } from '../../../../model/rule_schema/time_duration';
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Rule data source
|
||||
|
@ -92,15 +91,6 @@ export const RuleEsqlQuery = z.object({
|
|||
language: z.literal('esql'),
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Rule schedule
|
||||
|
||||
export type RuleSchedule = z.infer<typeof RuleSchedule>;
|
||||
export const RuleSchedule = z.object({
|
||||
interval: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }),
|
||||
lookback: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }),
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Rule name override
|
||||
|
||||
|
|
|
@ -45,10 +45,10 @@ import {
|
|||
RuleEsqlQuery,
|
||||
RuleKqlQuery,
|
||||
RuleNameOverrideObject,
|
||||
RuleSchedule,
|
||||
TimelineTemplateReference,
|
||||
TimestampOverrideObject,
|
||||
} from './diffable_field_types';
|
||||
import { RuleSchedule } from '../../../../model/rule_schema/rule_schedule';
|
||||
|
||||
export type DiffableCommonFields = z.infer<typeof DiffableCommonFields>;
|
||||
export const DiffableCommonFields = z.object({
|
||||
|
|
|
@ -5,14 +5,23 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getRulesSchemaMock } from '../../../api/detection_engine/model/rule_schema/mocks';
|
||||
import type { RuleResponse } from '../../../api/detection_engine';
|
||||
import { extractRuleSchedule } from './extract_rule_schedule';
|
||||
|
||||
describe('extractRuleSchedule', () => {
|
||||
it('normalizes lookback strings to seconds', () => {
|
||||
const mockRule = { ...getRulesSchemaMock(), from: 'now-6m', interval: '5m', to: 'now' };
|
||||
const normalizedRuleSchedule = extractRuleSchedule(mockRule);
|
||||
it('returns rule schedule', () => {
|
||||
const ruleSchedule = extractRuleSchedule({
|
||||
from: 'now-6m',
|
||||
interval: '5m',
|
||||
to: 'now',
|
||||
} as RuleResponse);
|
||||
|
||||
expect(normalizedRuleSchedule).toEqual({ interval: '5m', lookback: '60s' });
|
||||
expect(ruleSchedule).toEqual({ interval: '5m', from: 'now-6m', to: 'now' });
|
||||
});
|
||||
|
||||
it('returns default values', () => {
|
||||
const ruleSchedule = extractRuleSchedule({} as RuleResponse);
|
||||
|
||||
expect(ruleSchedule).toEqual({ interval: '5m', from: 'now-6m', to: 'now' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,63 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import dateMath from '@elastic/datemath';
|
||||
import { parseDuration } from '@kbn/alerting-plugin/common';
|
||||
|
||||
import { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
|
||||
import { normalizeDateMath } from '@kbn/securitysolution-utils/date_math';
|
||||
import type { RuleSchedule } from '../../../api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema';
|
||||
import type { RuleSchedule } from '../../../api/detection_engine/prebuilt_rules';
|
||||
|
||||
export const extractRuleSchedule = (rule: RuleResponse): RuleSchedule => {
|
||||
const interval = rule.interval ?? '5m';
|
||||
const interval = TimeDuration.parse(rule.interval) ?? new TimeDuration(5, 'm');
|
||||
const from = rule.from ?? 'now-6m';
|
||||
const to = rule.to ?? 'now';
|
||||
|
||||
const intervalDuration = parseInterval(interval);
|
||||
const driftToleranceDuration = parseDriftTolerance(from, to);
|
||||
|
||||
if (intervalDuration == null) {
|
||||
return {
|
||||
interval: `Cannot parse: interval="${interval}"`,
|
||||
lookback: `Cannot calculate due to invalid interval`,
|
||||
};
|
||||
}
|
||||
|
||||
if (driftToleranceDuration == null) {
|
||||
return {
|
||||
interval,
|
||||
lookback: `Cannot parse: from="${from}", to="${to}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const lookbackDuration = moment.duration().add(driftToleranceDuration).subtract(intervalDuration);
|
||||
const lookback = `${lookbackDuration.asSeconds()}s`;
|
||||
|
||||
return { interval, lookback };
|
||||
};
|
||||
|
||||
const parseInterval = (intervalString: string): moment.Duration | null => {
|
||||
try {
|
||||
const milliseconds = parseDuration(intervalString);
|
||||
return moment.duration(milliseconds);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseDriftTolerance = (from: string, to: string): moment.Duration | null => {
|
||||
const now = new Date();
|
||||
const fromDate = parseDateMathString(from, now);
|
||||
const toDate = parseDateMathString(to, now);
|
||||
|
||||
if (fromDate == null || toDate == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return moment.duration(toDate.diff(fromDate));
|
||||
};
|
||||
|
||||
const parseDateMathString = (dateMathString: string, now: Date): moment.Moment | null => {
|
||||
const parsedDate = dateMath.parse(dateMathString, { forceNow: now });
|
||||
return parsedDate != null && parsedDate.isValid() ? parsedDate : null;
|
||||
return {
|
||||
interval: interval.toString(),
|
||||
from: normalizeDateMath(from),
|
||||
to: normalizeDateMath(to),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -15,7 +15,8 @@ import { validateHistoryWindowStart } from './validate_history_window_start';
|
|||
const COMPONENT_PROPS = {
|
||||
idAria: 'historyWindowSize',
|
||||
dataTestSubj: 'historyWindowSize',
|
||||
timeTypes: ['m', 'h', 'd'],
|
||||
units: ['m', 'h', 'd'],
|
||||
minValue: 0,
|
||||
};
|
||||
|
||||
interface HistoryWindowStartEditProps {
|
||||
|
|
|
@ -15,27 +15,17 @@ describe('ScheduleItemField', () => {
|
|||
it('renders correctly', () => {
|
||||
const mockField = useFormFieldMock<string>();
|
||||
const wrapper = shallow(
|
||||
<ScheduleItemField
|
||||
dataTestSubj="schedule-item"
|
||||
idAria="idAria"
|
||||
isDisabled={false}
|
||||
field={mockField}
|
||||
/>
|
||||
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="schedule-item"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accepts a large number via user input', () => {
|
||||
it('accepts user input', () => {
|
||||
const mockField = useFormFieldMock<string>();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ScheduleItemField
|
||||
dataTestSubj="schedule-item"
|
||||
idAria="idAria"
|
||||
isDisabled={false}
|
||||
field={mockField}
|
||||
/>
|
||||
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -47,17 +37,20 @@ describe('ScheduleItemField', () => {
|
|||
expect(mockField.setValue).toHaveBeenCalledWith('5000000s');
|
||||
});
|
||||
|
||||
it('clamps a number value greater than MAX_SAFE_INTEGER to MAX_SAFE_INTEGER', () => {
|
||||
const unsafeInput = '99999999999999999999999';
|
||||
|
||||
it.each([
|
||||
[-10, -5],
|
||||
[-5, 0],
|
||||
[5, 10],
|
||||
[60, 90],
|
||||
])('saturates a value "%s" lower than minValue', (unsafeInput, expected) => {
|
||||
const mockField = useFormFieldMock<string>();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ScheduleItemField
|
||||
field={mockField}
|
||||
minValue={expected}
|
||||
dataTestSubj="schedule-item"
|
||||
idAria="idAria"
|
||||
isDisabled={false}
|
||||
field={mockField}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
@ -67,22 +60,42 @@ describe('ScheduleItemField', () => {
|
|||
.last()
|
||||
.simulate('change', { target: { value: unsafeInput } });
|
||||
|
||||
const expectedValue = `${Number.MAX_SAFE_INTEGER}s`;
|
||||
expect(mockField.setValue).toHaveBeenCalledWith(expectedValue);
|
||||
expect(mockField.setValue).toHaveBeenCalledWith(`${expected}s`);
|
||||
});
|
||||
|
||||
it('converts a non-numeric value to 0', () => {
|
||||
it.each([
|
||||
[-5, -10],
|
||||
[5, 0],
|
||||
[10, 5],
|
||||
[90, 60],
|
||||
])('saturates a value "%s" greater than maxValue', (unsafeInput, expected) => {
|
||||
const mockField = useFormFieldMock<string>();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ScheduleItemField
|
||||
field={mockField}
|
||||
maxValue={expected}
|
||||
dataTestSubj="schedule-item"
|
||||
idAria="idAria"
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="interval"]')
|
||||
.last()
|
||||
.simulate('change', { target: { value: unsafeInput } });
|
||||
|
||||
expect(mockField.setValue).toHaveBeenCalledWith(`${expected}s`);
|
||||
});
|
||||
|
||||
it('skips updating a non-numeric values', () => {
|
||||
const unsafeInput = 'this is not a number';
|
||||
|
||||
const mockField = useFormFieldMock<string>();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<ScheduleItemField
|
||||
dataTestSubj="schedule-item"
|
||||
idAria="idAria"
|
||||
isDisabled={false}
|
||||
field={mockField}
|
||||
/>
|
||||
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -91,6 +104,6 @@ describe('ScheduleItemField', () => {
|
|||
.last()
|
||||
.simulate('change', { target: { value: unsafeInput } });
|
||||
|
||||
expect(mockField.setValue).toHaveBeenCalledWith('0s');
|
||||
expect(mockField.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { EuiSelectProps, EuiFieldNumberProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
|
@ -14,8 +15,6 @@ import {
|
|||
EuiSelect,
|
||||
transparentize,
|
||||
} from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import type { FieldHook } from '../../../../shared_imports';
|
||||
|
@ -27,8 +26,9 @@ interface ScheduleItemProps {
|
|||
field: FieldHook<string>;
|
||||
dataTestSubj: string;
|
||||
idAria: string;
|
||||
isDisabled: boolean;
|
||||
minimumValue?: number;
|
||||
isDisabled?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
timeTypes?: string[];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
@ -67,24 +67,16 @@ const MyEuiSelect = styled(EuiSelect)`
|
|||
box-shadow: none;
|
||||
`;
|
||||
|
||||
const getNumberFromUserInput = (input: string, minimumValue = 0): number => {
|
||||
const number = parseInt(input, 10);
|
||||
if (Number.isNaN(number)) {
|
||||
return minimumValue;
|
||||
} else {
|
||||
return Math.max(minimumValue, Math.min(number, Number.MAX_SAFE_INTEGER));
|
||||
}
|
||||
};
|
||||
|
||||
export const ScheduleItemField = ({
|
||||
dataTestSubj,
|
||||
export function ScheduleItemField({
|
||||
field,
|
||||
idAria,
|
||||
isDisabled,
|
||||
minimumValue = 0,
|
||||
timeTypes = ['s', 'm', 'h'],
|
||||
dataTestSubj,
|
||||
idAria,
|
||||
minValue = Number.MIN_SAFE_INTEGER,
|
||||
maxValue = Number.MAX_SAFE_INTEGER,
|
||||
timeTypes = DEFAULT_TIME_DURATION_UNITS,
|
||||
fullWidth = false,
|
||||
}: ScheduleItemProps) => {
|
||||
}: ScheduleItemProps): JSX.Element {
|
||||
const [timeType, setTimeType] = useState(timeTypes[0]);
|
||||
const [timeVal, setTimeVal] = useState<number>(0);
|
||||
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
|
||||
|
@ -100,38 +92,40 @@ export const ScheduleItemField = ({
|
|||
|
||||
const onChangeTimeVal = useCallback<NonNullable<EuiFieldNumberProps['onChange']>>(
|
||||
(e) => {
|
||||
const sanitizedValue = getNumberFromUserInput(e.target.value, minimumValue);
|
||||
setTimeVal(sanitizedValue);
|
||||
setValue(`${sanitizedValue}${timeType}`);
|
||||
const number = parseInt(e.target.value, 10);
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimeValue = saturate(number, minValue, maxValue);
|
||||
|
||||
setTimeVal(newTimeValue);
|
||||
setValue(`${newTimeValue}${timeType}`);
|
||||
},
|
||||
[minimumValue, setValue, timeType]
|
||||
[minValue, maxValue, setValue, timeType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== `${timeVal}${timeType}`) {
|
||||
const filterTimeVal = value.match(/\d+/g);
|
||||
const filterTimeType = value.match(/[a-zA-Z]+/g);
|
||||
if (
|
||||
!isEmpty(filterTimeVal) &&
|
||||
filterTimeVal != null &&
|
||||
!isNaN(Number(filterTimeVal[0])) &&
|
||||
Number(filterTimeVal[0]) !== Number(timeVal)
|
||||
) {
|
||||
setTimeVal(Number(filterTimeVal[0]));
|
||||
}
|
||||
if (
|
||||
!isEmpty(filterTimeType) &&
|
||||
filterTimeType != null &&
|
||||
timeTypes.includes(filterTimeType[0]) &&
|
||||
filterTimeType[0] !== timeType
|
||||
) {
|
||||
setTimeType(filterTimeType[0]);
|
||||
}
|
||||
if (value === `${timeVal}${timeType}`) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNegative = value.startsWith('-');
|
||||
const durationRegexp = new RegExp(`^\\-?(\\d+)(${timeTypes.join('|')})$`);
|
||||
const durationMatchArray = value.match(durationRegexp);
|
||||
|
||||
if (!durationMatchArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, timeStr, unit] = durationMatchArray;
|
||||
const time = parseInt(timeStr, 10) * (isNegative ? -1 : 1);
|
||||
|
||||
setTimeVal(time);
|
||||
setTimeType(unit);
|
||||
}, [timeType, timeTypes, timeVal, value]);
|
||||
|
||||
// EUI missing some props
|
||||
const rest = { disabled: isDisabled };
|
||||
const label = useMemo(
|
||||
() => (
|
||||
<EuiFlexGroup gutterSize="s" justifyContent="flexStart" alignItems="center">
|
||||
|
@ -161,21 +155,27 @@ export const ScheduleItemField = ({
|
|||
<MyEuiSelect
|
||||
fullWidth
|
||||
options={timeTypeOptions.filter((type) => timeTypes.includes(type.value))}
|
||||
onChange={onChangeTimeType}
|
||||
value={timeType}
|
||||
onChange={onChangeTimeType}
|
||||
disabled={isDisabled}
|
||||
aria-label={field.label}
|
||||
data-test-subj="timeType"
|
||||
{...rest}
|
||||
/>
|
||||
}
|
||||
fullWidth
|
||||
min={minimumValue}
|
||||
max={Number.MAX_SAFE_INTEGER}
|
||||
onChange={onChangeTimeVal}
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
value={timeVal}
|
||||
onChange={onChangeTimeVal}
|
||||
disabled={isDisabled}
|
||||
data-test-subj="interval"
|
||||
{...rest}
|
||||
/>
|
||||
</StyledEuiFormRow>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TIME_DURATION_UNITS = ['s', 'm', 'h'];
|
||||
|
||||
function saturate(input: number, minValue: number, maxValue: number): number {
|
||||
return Math.max(minValue, Math.min(input, maxValue));
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
|
|||
idAria: 'detectionEngineStepScheduleRuleInterval',
|
||||
isDisabled: isLoading,
|
||||
dataTestSubj: 'detectionEngineStepScheduleRuleInterval',
|
||||
minimumValue: 1,
|
||||
minValue: 1,
|
||||
}}
|
||||
/>
|
||||
<UseField
|
||||
|
@ -58,7 +58,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
|
|||
idAria: 'detectionEngineStepScheduleRuleFrom',
|
||||
isDisabled: isLoading,
|
||||
dataTestSubj: 'detectionEngineStepScheduleRuleFrom',
|
||||
minimumValue: 1,
|
||||
minValue: 0,
|
||||
}}
|
||||
/>
|
||||
</StyledForm>
|
||||
|
|
|
@ -27,7 +27,6 @@ import type {
|
|||
} from '../../../../detections/pages/detection_engine/rules/types';
|
||||
import { AlertSuppressionDurationType } from '../../../../detections/pages/detection_engine/rules/types';
|
||||
import {
|
||||
getTimeTypeValue,
|
||||
formatDefineStepData,
|
||||
formatScheduleStepData,
|
||||
formatAboutStepData,
|
||||
|
@ -54,56 +53,6 @@ import {
|
|||
} from '../../../rule_creation/components/alert_suppression_edit';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('getTimeTypeValue', () => {
|
||||
test('returns timeObj with value 0 if no time value found', () => {
|
||||
const result = getTimeTypeValue('m');
|
||||
|
||||
expect(result).toEqual({ unit: 'm', value: 0 });
|
||||
});
|
||||
|
||||
test('returns timeObj with unit set to default unit value of "ms" if no expected time type found', () => {
|
||||
const result = getTimeTypeValue('5l');
|
||||
|
||||
expect(result).toEqual({ unit: 'ms', value: 5 });
|
||||
});
|
||||
|
||||
test('returns timeObj with unit of s and value 5 when time is 5s ', () => {
|
||||
const result = getTimeTypeValue('5s');
|
||||
|
||||
expect(result).toEqual({ unit: 's', value: 5 });
|
||||
});
|
||||
|
||||
test('returns timeObj with unit of m and value 5 when time is 5m ', () => {
|
||||
const result = getTimeTypeValue('5m');
|
||||
|
||||
expect(result).toEqual({ unit: 'm', value: 5 });
|
||||
});
|
||||
|
||||
test('returns timeObj with unit of h and value 5 when time is 5h ', () => {
|
||||
const result = getTimeTypeValue('5h');
|
||||
|
||||
expect(result).toEqual({ unit: 'h', value: 5 });
|
||||
});
|
||||
|
||||
test('returns timeObj with value of 5 when time is float like 5.6m ', () => {
|
||||
const result = getTimeTypeValue('5m');
|
||||
|
||||
expect(result).toEqual({ unit: 'm', value: 5 });
|
||||
});
|
||||
|
||||
test('returns timeObj with value of 0 and unit of "ms" if random string passed in', () => {
|
||||
const result = getTimeTypeValue('random');
|
||||
|
||||
expect(result).toEqual({ unit: 'ms', value: 0 });
|
||||
});
|
||||
|
||||
test('returns timeObj with unit of d and value 5 when time is 5d ', () => {
|
||||
const result = getTimeTypeValue('5d');
|
||||
|
||||
expect(result).toEqual({ unit: 'd', value: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEmptyThreats', () => {
|
||||
let mockThreat: Threat;
|
||||
|
||||
|
@ -639,12 +588,9 @@ describe('helpers', () => {
|
|||
test('returns formatted object as ScheduleStepRuleJson', () => {
|
||||
const result = formatScheduleStepData(mockData);
|
||||
const expected: ScheduleStepRuleJson = {
|
||||
from: 'now-660s',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
interval: '5m',
|
||||
meta: {
|
||||
from: '6m',
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
|
@ -657,12 +603,9 @@ describe('helpers', () => {
|
|||
delete mockStepData.to;
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
const expected: ScheduleStepRuleJson = {
|
||||
from: 'now-660s',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
interval: '5m',
|
||||
meta: {
|
||||
from: '6m',
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
|
@ -675,51 +618,34 @@ describe('helpers', () => {
|
|||
};
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
const expected: ScheduleStepRuleJson = {
|
||||
from: 'now-660s',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
interval: '5m',
|
||||
meta: {
|
||||
from: '6m',
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('returns formatted object if "from" random string', () => {
|
||||
test('returns unchanged data when "from" is a random string', () => {
|
||||
const mockStepData: ScheduleStepRule = {
|
||||
...mockData,
|
||||
from: 'random',
|
||||
};
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
const expected: ScheduleStepRuleJson = {
|
||||
from: 'now-300s',
|
||||
to: 'now',
|
||||
interval: '5m',
|
||||
meta: {
|
||||
from: 'random',
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
|
||||
expect(result).toMatchObject(mockStepData);
|
||||
});
|
||||
|
||||
test('returns formatted object if "interval" random string', () => {
|
||||
test('returns unchanged data when "interval" is a random string', () => {
|
||||
const mockStepData: ScheduleStepRule = {
|
||||
...mockData,
|
||||
interval: 'random',
|
||||
};
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
const expected: ScheduleStepRuleJson = {
|
||||
from: 'now-360s',
|
||||
to: 'now',
|
||||
interval: 'random',
|
||||
meta: {
|
||||
from: '6m',
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
const result = formatScheduleStepData(mockStepData);
|
||||
|
||||
expect(result).toMatchObject(mockStepData);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import { has, isEmpty, get } from 'lodash/fp';
|
||||
import type { Unit } from '@kbn/datemath';
|
||||
import moment from 'moment';
|
||||
import deepmerge from 'deepmerge';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
|
@ -33,6 +32,7 @@ import type {
|
|||
|
||||
import type { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
|
||||
import { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
|
||||
import { assertUnreachable } from '../../../../../common/utility_types';
|
||||
import {
|
||||
transformAlertToRuleAction,
|
||||
|
@ -570,22 +570,20 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep
|
|||
|
||||
export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => {
|
||||
const { ...formatScheduleData } = scheduleData;
|
||||
if (!isEmpty(formatScheduleData.interval) && !isEmpty(formatScheduleData.from)) {
|
||||
const { unit: intervalUnit, value: intervalValue } = getTimeTypeValue(
|
||||
formatScheduleData.interval
|
||||
);
|
||||
const { unit: fromUnit, value: fromValue } = getTimeTypeValue(formatScheduleData.from);
|
||||
const duration = moment.duration(intervalValue, intervalUnit);
|
||||
duration.add(fromValue, fromUnit);
|
||||
formatScheduleData.from = `now-${duration.asSeconds()}s`;
|
||||
|
||||
const interval = TimeDuration.parse(formatScheduleData.interval ?? '');
|
||||
const lookBack = TimeDuration.parse(formatScheduleData.from ?? '');
|
||||
|
||||
if (interval !== undefined && lookBack !== undefined) {
|
||||
const fromOffset = TimeDuration.fromMilliseconds(
|
||||
interval.toMilliseconds() + lookBack.toMilliseconds()
|
||||
).toString();
|
||||
|
||||
formatScheduleData.from = `now-${fromOffset}`;
|
||||
formatScheduleData.to = 'now';
|
||||
}
|
||||
return {
|
||||
...formatScheduleData,
|
||||
meta: {
|
||||
from: scheduleData.from,
|
||||
},
|
||||
};
|
||||
|
||||
return formatScheduleData;
|
||||
};
|
||||
|
||||
export const formatAboutStepData = (
|
||||
|
|
|
@ -28,18 +28,6 @@ function findChildByTextContent(parent: Element, textContent: string): HTMLEleme
|
|||
) as HTMLElement;
|
||||
}
|
||||
|
||||
/*
|
||||
Finds a diff line element (".diff-line") that contains a particular text content.
|
||||
Match doesn't have to be exact, it's enough for the line to include the text.
|
||||
*/
|
||||
function findDiffLineContaining(text: string): Element | null {
|
||||
const foundLine = Array.from(document.querySelectorAll('.diff-line')).find((element) =>
|
||||
(element.textContent || '').includes(text)
|
||||
);
|
||||
|
||||
return foundLine || null;
|
||||
}
|
||||
|
||||
describe('Rule upgrade workflow: viewing rule changes in JSON diff view', () => {
|
||||
it.each(['light', 'dark'] as const)(
|
||||
'User can see precisely how property values would change after upgrade - %s theme',
|
||||
|
@ -210,68 +198,6 @@ describe('Rule upgrade workflow: viewing rule changes in JSON diff view', () =>
|
|||
);
|
||||
});
|
||||
|
||||
it('Properties with semantically equal values should not be shown as modified', () => {
|
||||
const oldRule: RuleResponse = {
|
||||
...savedRuleMock,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const newRule: RuleResponse = {
|
||||
...savedRuleMock,
|
||||
version: 2,
|
||||
};
|
||||
|
||||
/* DURATION */
|
||||
/* Semantically equal durations should not be shown as modified */
|
||||
const { rerender } = render(
|
||||
<RuleDiffTab
|
||||
oldRule={{ ...oldRule, from: 'now-1h' }}
|
||||
newRule={{ ...newRule, from: 'now-60m' }}
|
||||
/>
|
||||
);
|
||||
expect(findDiffLineContaining('"from":')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RuleDiffTab
|
||||
oldRule={{ ...oldRule, from: 'now-1h' }}
|
||||
newRule={{ ...newRule, from: 'now-3600s' }}
|
||||
/>
|
||||
);
|
||||
expect(findDiffLineContaining('"from":')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RuleDiffTab
|
||||
oldRule={{ ...oldRule, from: 'now-7200s' }}
|
||||
newRule={{ ...newRule, from: 'now-2h' }}
|
||||
/>
|
||||
);
|
||||
expect(findDiffLineContaining('"from":')).toBeNull();
|
||||
|
||||
/* Semantically different durations should generate diff */
|
||||
rerender(
|
||||
<RuleDiffTab
|
||||
oldRule={{ ...oldRule, from: 'now-7260s' }}
|
||||
newRule={{ ...newRule, from: 'now-2h' }}
|
||||
/>
|
||||
);
|
||||
expect(findDiffLineContaining('- "from": "now-7260s",+ "from": "now-7200s",')).not.toBeNull();
|
||||
|
||||
/* NOTE - Investigation guide */
|
||||
rerender(<RuleDiffTab oldRule={{ ...oldRule, note: '' }} newRule={{ ...newRule }} />);
|
||||
expect(findDiffLineContaining('"note":')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RuleDiffTab oldRule={{ ...oldRule, note: '' }} newRule={{ ...newRule, note: undefined }} />
|
||||
);
|
||||
expect(findDiffLineContaining('"note":')).toBeNull();
|
||||
|
||||
rerender(<RuleDiffTab oldRule={{ ...oldRule }} newRule={{ ...newRule, note: '' }} />);
|
||||
expect(findDiffLineContaining('"note":')).toBeNull();
|
||||
|
||||
rerender(<RuleDiffTab oldRule={{ ...oldRule }} newRule={{ ...newRule, note: 'abc' }} />);
|
||||
expect(findDiffLineContaining('- "note": "",+ "note": "abc",')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Unchanged sections of a rule should be hidden by default', async () => {
|
||||
const oldRule: RuleResponse = {
|
||||
...savedRuleMock,
|
||||
|
|
|
@ -0,0 +1,392 @@
|
|||
/*
|
||||
* 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 { RuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import type { ThreeWayDiff } from '../../../../../../common/api/detection_engine';
|
||||
import { getFieldDiffsForRuleSchedule } from './get_field_diffs_for_grouped_fields';
|
||||
|
||||
describe('getFieldDiffsForRuleSchedule', () => {
|
||||
describe('full rule schedule', () => {
|
||||
it('returns interval diff', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-8m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-8m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns from diff', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-8m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '10m',
|
||||
from: 'now-7m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: 'now-8m',
|
||||
targetVersion: 'now-7m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns to diff', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-5m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '10m',
|
||||
from: 'now-5m',
|
||||
to: 'now-2m',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: 'now',
|
||||
targetVersion: 'now-2m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns full diff', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-5m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-6m',
|
||||
to: 'now-2m',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: 'now-5m',
|
||||
targetVersion: 'now-6m',
|
||||
},
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: 'now',
|
||||
targetVersion: 'now-2m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns full diff when current lookback is negative', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-5m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: 'now-5m',
|
||||
targetVersion: 'now-15m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns full diff when current to is not now', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-15m',
|
||||
to: 'now-2m',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: 'now-2m',
|
||||
targetVersion: 'now',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns full diff when target lookback is negative', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-5m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: 'now-15m',
|
||||
targetVersion: 'now-5m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns full diff when target to is not now', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-15m',
|
||||
to: 'now-2m',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: 'now',
|
||||
targetVersion: 'now-2m',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns diff with current undefined', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: undefined,
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-8m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule | undefined>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: '',
|
||||
targetVersion: 'now-8m',
|
||||
},
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: '',
|
||||
targetVersion: 'now',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns diff with target undefined', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '11m',
|
||||
from: 'now-8m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: undefined,
|
||||
} as ThreeWayDiff<RuleSchedule | undefined>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '11m',
|
||||
targetVersion: '',
|
||||
},
|
||||
{
|
||||
fieldName: 'from',
|
||||
currentVersion: 'now-8m',
|
||||
targetVersion: '',
|
||||
},
|
||||
{
|
||||
fieldName: 'to',
|
||||
currentVersion: 'now',
|
||||
targetVersion: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple rule schedule', () => {
|
||||
it('returns diff', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'lookback',
|
||||
currentVersion: '1m',
|
||||
targetVersion: '0s',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns diff when current is undefined', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: undefined,
|
||||
target_version: {
|
||||
interval: '11m',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule | undefined>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '',
|
||||
targetVersion: '11m',
|
||||
},
|
||||
{
|
||||
fieldName: 'lookback',
|
||||
currentVersion: '',
|
||||
targetVersion: '0s',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns diff when target is undefined', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-11m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: undefined,
|
||||
} as ThreeWayDiff<RuleSchedule | undefined>);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: '10m',
|
||||
targetVersion: '',
|
||||
},
|
||||
{
|
||||
fieldName: 'lookback',
|
||||
currentVersion: '1m',
|
||||
targetVersion: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no diff', () => {
|
||||
it('returns empty array for equal versions', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: {
|
||||
interval: '10m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
target_version: {
|
||||
interval: '10m',
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
} as ThreeWayDiff<RuleSchedule>);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for undefined versions', () => {
|
||||
const result = getFieldDiffsForRuleSchedule({
|
||||
current_version: undefined,
|
||||
target_version: undefined,
|
||||
} as ThreeWayDiff<RuleSchedule | undefined>);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,12 +6,18 @@
|
|||
*/
|
||||
|
||||
import stringify from 'json-stable-stringify';
|
||||
import type {
|
||||
RuleSchedule,
|
||||
SimpleRuleSchedule,
|
||||
} from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import { toSimpleRuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import type {
|
||||
AllFieldsDiff,
|
||||
RuleFieldsDiffWithDataSource,
|
||||
RuleFieldsDiffWithEqlQuery,
|
||||
RuleFieldsDiffWithEsqlQuery,
|
||||
RuleFieldsDiffWithKqlQuery,
|
||||
ThreeWayDiff,
|
||||
} from '../../../../../../common/api/detection_engine';
|
||||
import type { FieldDiff } from '../../../model/rule_details/rule_field_diff';
|
||||
|
||||
|
@ -277,34 +283,75 @@ export const getFieldDiffsForThreatQuery = (
|
|||
};
|
||||
|
||||
export const getFieldDiffsForRuleSchedule = (
|
||||
ruleScheduleThreeWayDiff: AllFieldsDiff['rule_schedule']
|
||||
ruleScheduleThreeWayDiff: ThreeWayDiff<RuleSchedule | undefined>
|
||||
): FieldDiff[] => {
|
||||
return [
|
||||
...(ruleScheduleThreeWayDiff.current_version?.interval !==
|
||||
ruleScheduleThreeWayDiff.target_version?.interval
|
||||
? [
|
||||
{
|
||||
fieldName: 'interval',
|
||||
currentVersion: sortAndStringifyJson(
|
||||
ruleScheduleThreeWayDiff.current_version?.interval
|
||||
),
|
||||
targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.interval),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(ruleScheduleThreeWayDiff.current_version?.lookback !==
|
||||
ruleScheduleThreeWayDiff.target_version?.lookback
|
||||
? [
|
||||
{
|
||||
fieldName: 'lookback',
|
||||
currentVersion: sortAndStringifyJson(
|
||||
ruleScheduleThreeWayDiff.current_version?.lookback
|
||||
),
|
||||
targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.lookback),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const fieldsDiff: FieldDiff[] = [];
|
||||
|
||||
const current = ruleScheduleThreeWayDiff.current_version;
|
||||
const target = ruleScheduleThreeWayDiff.target_version;
|
||||
|
||||
const currentSimpleRuleSchedule = current ? toSimpleRuleSchedule(current) : undefined;
|
||||
const targetSimpleRuleSchedule = target ? toSimpleRuleSchedule(target) : undefined;
|
||||
|
||||
const isCurrentSimpleRuleScheduleValid = !current || (current && currentSimpleRuleSchedule);
|
||||
const isTargetSimpleRuleScheduleValid = !target || (target && targetSimpleRuleSchedule);
|
||||
|
||||
// Show simple rule schedule diff only when current and target versions are convertable
|
||||
// to simple rule schedule or one of the versions is undefined.
|
||||
if (isCurrentSimpleRuleScheduleValid && isTargetSimpleRuleScheduleValid) {
|
||||
return getFieldDiffsForSimpleRuleSchedule(currentSimpleRuleSchedule, targetSimpleRuleSchedule);
|
||||
}
|
||||
|
||||
if (current?.interval !== target?.interval) {
|
||||
fieldsDiff.push({
|
||||
fieldName: 'interval',
|
||||
currentVersion: sortAndStringifyJson(current?.interval),
|
||||
targetVersion: sortAndStringifyJson(target?.interval),
|
||||
});
|
||||
}
|
||||
|
||||
if (current?.from !== target?.from) {
|
||||
fieldsDiff.push({
|
||||
fieldName: 'from',
|
||||
currentVersion: sortAndStringifyJson(current?.from),
|
||||
targetVersion: sortAndStringifyJson(target?.from),
|
||||
});
|
||||
}
|
||||
|
||||
if (current?.to !== target?.to) {
|
||||
fieldsDiff.push({
|
||||
fieldName: 'to',
|
||||
currentVersion: sortAndStringifyJson(current?.to),
|
||||
targetVersion: sortAndStringifyJson(target?.to),
|
||||
});
|
||||
}
|
||||
|
||||
return fieldsDiff;
|
||||
};
|
||||
|
||||
const getFieldDiffsForSimpleRuleSchedule = (
|
||||
current: SimpleRuleSchedule | undefined,
|
||||
target: SimpleRuleSchedule | undefined
|
||||
): FieldDiff[] => {
|
||||
const fieldsDiff: FieldDiff[] = [];
|
||||
|
||||
if (current?.interval !== target?.interval) {
|
||||
fieldsDiff.push({
|
||||
fieldName: 'interval',
|
||||
currentVersion: sortAndStringifyJson(current?.interval),
|
||||
targetVersion: sortAndStringifyJson(target?.interval),
|
||||
});
|
||||
}
|
||||
|
||||
if (current?.lookback !== target?.lookback) {
|
||||
fieldsDiff.push({
|
||||
fieldName: 'lookback',
|
||||
currentVersion: sortAndStringifyJson(current?.lookback),
|
||||
targetVersion: sortAndStringifyJson(target?.lookback),
|
||||
});
|
||||
}
|
||||
|
||||
return fieldsDiff;
|
||||
};
|
||||
|
||||
export const getFieldDiffsForRuleNameOverride = (
|
||||
|
|
|
@ -18,14 +18,10 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { normalizeMachineLearningJobIds } from '../../../../../common/detection_engine/utils';
|
||||
import {
|
||||
formatScheduleStepData,
|
||||
filterEmptyThreats,
|
||||
} from '../../../rule_creation_ui/pages/rule_creation/helpers';
|
||||
import { filterEmptyThreats } from '../../../rule_creation_ui/pages/rule_creation/helpers';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen';
|
||||
import { DiffView } from './json_diff/diff_view';
|
||||
import * as i18n from './json_diff/translations';
|
||||
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
|
||||
|
||||
/* Inclding these properties in diff display might be confusing to users. */
|
||||
const HIDDEN_PROPERTIES: Array<keyof RuleResponse> = [
|
||||
|
@ -79,20 +75,6 @@ const sortAndStringifyJson = (jsObject: Record<string, unknown>): string =>
|
|||
const normalizeRule = (originalRule: RuleResponse): RuleResponse => {
|
||||
const rule = { ...originalRule };
|
||||
|
||||
/*
|
||||
Convert the "from" property value to a humanized duration string, like 'now-1m' or 'now-2h'.
|
||||
Conversion is needed to skip showing the diff for the "from" property when the same
|
||||
duration is represented in different time units. For instance, 'now-1h' and 'now-3600s'
|
||||
indicate a one-hour duration.
|
||||
The same helper is used in the rule editing UI to format "from" before submitting the edits.
|
||||
So, after the rule is saved, the "from" property unit/value might differ from what's in the package.
|
||||
*/
|
||||
rule.from = formatScheduleStepData({
|
||||
interval: rule.interval,
|
||||
from: getHumanizedDuration(rule.from, rule.interval),
|
||||
to: rule.to,
|
||||
}).from;
|
||||
|
||||
/*
|
||||
Default "note" to an empty string if it's not present.
|
||||
Sometimes, in a new version of a rule, the "note" value equals an empty string, while
|
||||
|
|
|
@ -8,9 +8,10 @@
|
|||
import React from 'react';
|
||||
import { EuiDescriptionList, EuiText } from '@elastic/eui';
|
||||
import type { EuiDescriptionListProps } from '@elastic/eui';
|
||||
import { normalizeDateMath } from '@kbn/securitysolution-utils/date_math';
|
||||
import { toSimpleRuleSchedule } from '../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import { IntervalAbbrScreenReader } from '../../../../common/components/accessibility';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
|
||||
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
|
||||
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
|
||||
import * as i18n from './translations';
|
||||
|
||||
|
@ -36,16 +37,12 @@ const Interval = ({ interval }: IntervalProps) => (
|
|||
<AccessibleTimeValue timeValue={interval} data-test-subj="intervalPropertyValue" />
|
||||
);
|
||||
|
||||
interface FromProps {
|
||||
from: string;
|
||||
interval: string;
|
||||
interface LookBackProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const From = ({ from, interval }: FromProps) => (
|
||||
<AccessibleTimeValue
|
||||
timeValue={getHumanizedDuration(from, interval)}
|
||||
data-test-subj={`fromPropertyValue-${from}`}
|
||||
/>
|
||||
const LookBack = ({ value }: LookBackProps) => (
|
||||
<AccessibleTimeValue timeValue={value} data-test-subj={`lookBackPropertyValue-${value}`} />
|
||||
);
|
||||
|
||||
export interface RuleScheduleSectionProps extends React.ComponentProps<typeof EuiDescriptionList> {
|
||||
|
@ -62,18 +59,46 @@ export const RuleScheduleSection = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const ruleSectionListItems = [];
|
||||
const to = rule.to ?? 'now';
|
||||
|
||||
ruleSectionListItems.push(
|
||||
{
|
||||
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
|
||||
description: <Interval interval={rule.interval} />,
|
||||
},
|
||||
{
|
||||
title: <span data-test-subj="fromPropertyTitle">{i18n.FROM_FIELD_LABEL}</span>,
|
||||
description: <From from={rule.from} interval={rule.interval} />,
|
||||
}
|
||||
);
|
||||
const simpleRuleSchedule = toSimpleRuleSchedule({
|
||||
interval: rule.interval,
|
||||
from: rule.from,
|
||||
to,
|
||||
});
|
||||
|
||||
const ruleSectionListItems = !simpleRuleSchedule
|
||||
? [
|
||||
{
|
||||
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
|
||||
description: <Interval interval={rule.interval} />,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span data-test-subj="fromToPropertyTitle">
|
||||
{i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL}
|
||||
</span>
|
||||
),
|
||||
description: (
|
||||
<span data-test-subj="fromToPropertyValue">
|
||||
{i18n.RULE_SOURCE_EVENTS_TIME_RANGE(
|
||||
normalizeDateMath(rule.from),
|
||||
normalizeDateMath(to)
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
|
||||
description: <Interval interval={simpleRuleSchedule.interval} />,
|
||||
},
|
||||
{
|
||||
title: <span data-test-subj="lookBackPropertyTitle">{i18n.LOOK_BACK_FIELD_LABEL}</span>,
|
||||
description: <LookBack value={simpleRuleSchedule.lookback} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-test-subj="listItemColumnStepRuleDescription">
|
||||
|
|
|
@ -5,14 +5,23 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { SimpleRuleSchedule } from '../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import { toSimpleRuleSchedule } from '../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import { stringifyToSortedJson } from '../utils';
|
||||
import type { DiffableAllFields } from '../../../../../../../../common/api/detection_engine';
|
||||
import type { SubfieldChange } from '../types';
|
||||
|
||||
export const getSubfieldChangesForRuleSchedule = (
|
||||
export function getSubfieldChangesForRuleSchedule(
|
||||
oldFieldValue?: DiffableAllFields['rule_schedule'],
|
||||
newFieldValue?: DiffableAllFields['rule_schedule']
|
||||
): SubfieldChange[] => {
|
||||
): SubfieldChange[] {
|
||||
const oldSimpleRuleSchedule = oldFieldValue ? toSimpleRuleSchedule(oldFieldValue) : undefined;
|
||||
const newSimpleRuleSchedule = newFieldValue ? toSimpleRuleSchedule(newFieldValue) : undefined;
|
||||
|
||||
if (oldSimpleRuleSchedule && newSimpleRuleSchedule) {
|
||||
return getSubfieldChangesForSimpleRuleSchedule(oldSimpleRuleSchedule, newSimpleRuleSchedule);
|
||||
}
|
||||
|
||||
const changes: SubfieldChange[] = [];
|
||||
|
||||
if (oldFieldValue?.interval !== newFieldValue?.interval) {
|
||||
|
@ -23,13 +32,46 @@ export const getSubfieldChangesForRuleSchedule = (
|
|||
});
|
||||
}
|
||||
|
||||
if (oldFieldValue?.lookback !== newFieldValue?.lookback) {
|
||||
if (oldFieldValue?.from !== newFieldValue?.from) {
|
||||
changes.push({
|
||||
subfieldName: 'lookback',
|
||||
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.lookback),
|
||||
newSubfieldValue: stringifyToSortedJson(newFieldValue?.lookback),
|
||||
subfieldName: 'from',
|
||||
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.from),
|
||||
newSubfieldValue: stringifyToSortedJson(newFieldValue?.from),
|
||||
});
|
||||
}
|
||||
|
||||
if (oldFieldValue?.to !== newFieldValue?.to) {
|
||||
changes.push({
|
||||
subfieldName: 'to',
|
||||
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.to),
|
||||
newSubfieldValue: stringifyToSortedJson(newFieldValue?.to),
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
};
|
||||
}
|
||||
|
||||
function getSubfieldChangesForSimpleRuleSchedule(
|
||||
oldFieldValue: SimpleRuleSchedule,
|
||||
newFieldValue: SimpleRuleSchedule
|
||||
): SubfieldChange[] {
|
||||
const changes: SubfieldChange[] = [];
|
||||
|
||||
if (oldFieldValue.interval !== newFieldValue.interval) {
|
||||
changes.push({
|
||||
subfieldName: 'interval',
|
||||
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.interval),
|
||||
newSubfieldValue: stringifyToSortedJson(newFieldValue?.interval),
|
||||
});
|
||||
}
|
||||
|
||||
if (oldFieldValue.lookback !== newFieldValue.lookback) {
|
||||
changes.push({
|
||||
subfieldName: 'lookback',
|
||||
oldSubfieldValue: stringifyToSortedJson(oldFieldValue.lookback),
|
||||
newSubfieldValue: stringifyToSortedJson(newFieldValue.lookback),
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
|
|
@ -65,12 +65,6 @@ import {
|
|||
ruleNameOverrideSerializer,
|
||||
ruleNameOverrideSchema,
|
||||
} from './fields/rule_name_override';
|
||||
import {
|
||||
RuleScheduleEdit,
|
||||
ruleScheduleSchema,
|
||||
ruleScheduleDeserializer,
|
||||
ruleScheduleSerializer,
|
||||
} from './fields/rule_schedule';
|
||||
import { SetupEdit, setupSchema } from './fields/setup';
|
||||
import { SeverityEdit } from './fields/severity';
|
||||
import {
|
||||
|
@ -92,6 +86,7 @@ import {
|
|||
timestampOverrideSerializer,
|
||||
timestampOverrideSchema,
|
||||
} from './fields/timestamp_override';
|
||||
import { RuleScheduleForm } from './fields/rule_schedule';
|
||||
|
||||
interface CommonRuleFieldEditProps {
|
||||
fieldName: UpgradeableCommonFields;
|
||||
|
@ -200,14 +195,7 @@ export function CommonRuleFieldEdit({ fieldName }: CommonRuleFieldEditProps) {
|
|||
/>
|
||||
);
|
||||
case 'rule_schedule':
|
||||
return (
|
||||
<RuleFieldEditFormWrapper
|
||||
component={RuleScheduleEdit}
|
||||
ruleFieldFormSchema={ruleScheduleSchema}
|
||||
serializer={ruleScheduleSerializer}
|
||||
deserializer={ruleScheduleDeserializer}
|
||||
/>
|
||||
);
|
||||
return <RuleScheduleForm />;
|
||||
case 'setup':
|
||||
return <RuleFieldEditFormWrapper component={SetupEdit} ruleFieldFormSchema={setupSchema} />;
|
||||
case 'severity':
|
||||
|
|
|
@ -1,56 +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 React from 'react';
|
||||
import { parseDuration } from '@kbn/alerting-plugin/common';
|
||||
import { type FormSchema, type FormData, UseField } from '../../../../../../../shared_imports';
|
||||
import { schema } from '../../../../../../rule_creation_ui/components/step_schedule_rule/schema';
|
||||
import type { RuleSchedule } from '../../../../../../../../common/api/detection_engine';
|
||||
import { secondsToDurationString } from '../../../../../../../detections/pages/detection_engine/rules/helpers';
|
||||
import { ScheduleItemField } from '../../../../../../rule_creation/components/schedule_item_field';
|
||||
|
||||
export const ruleScheduleSchema = {
|
||||
interval: schema.interval,
|
||||
from: schema.from,
|
||||
} as FormSchema<{
|
||||
interval: string;
|
||||
from: string;
|
||||
}>;
|
||||
|
||||
const componentProps = {
|
||||
minimumValue: 1,
|
||||
};
|
||||
|
||||
export function RuleScheduleEdit(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<UseField path="interval" component={ScheduleItemField} componentProps={componentProps} />
|
||||
<UseField path="from" component={ScheduleItemField} componentProps={componentProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ruleScheduleDeserializer(defaultValue: FormData) {
|
||||
const lookbackSeconds = parseDuration(defaultValue.rule_schedule.lookback) / 1000;
|
||||
const lookbackHumanized = secondsToDurationString(lookbackSeconds);
|
||||
|
||||
return {
|
||||
interval: defaultValue.rule_schedule.interval,
|
||||
from: lookbackHumanized,
|
||||
};
|
||||
}
|
||||
|
||||
export function ruleScheduleSerializer(formData: FormData): {
|
||||
rule_schedule: RuleSchedule;
|
||||
} {
|
||||
return {
|
||||
rule_schedule: {
|
||||
interval: formData.interval,
|
||||
lookback: formData.from,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components';
|
||||
import type { FieldConfig } from '../../../../../../../../shared_imports';
|
||||
import { UseField } from '../../../../../../../../shared_imports';
|
||||
import { ScheduleItemField } from '../../../../../../../rule_creation/components/schedule_item_field';
|
||||
import * as i18n from './translations';
|
||||
import { dateMathValidator } from './validators/date_math_validator';
|
||||
|
||||
export function FullRuleScheduleAdapter(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<UseField
|
||||
path="interval"
|
||||
config={INTERVAL_FIELD_CONFIG}
|
||||
component={ScheduleItemField}
|
||||
componentProps={INTERVAL_COMPONENT_PROPS}
|
||||
/>
|
||||
<UseField path="from" config={FROM_FIELD_CONFIG} component={TextField} />
|
||||
<UseField path="to" config={TO_FIELD_CONFIG} component={TextField} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const INTERVAL_COMPONENT_PROPS = {
|
||||
minValue: 1,
|
||||
};
|
||||
|
||||
const INTERVAL_FIELD_CONFIG: FieldConfig<string> = {
|
||||
label: i18n.INTERVAL_FIELD_LABEL,
|
||||
helpText: i18n.INTERVAL_FIELD_HELP_TEXT,
|
||||
};
|
||||
|
||||
const FROM_FIELD_CONFIG: FieldConfig<string> = {
|
||||
label: i18n.FROM_FIELD_LABEL,
|
||||
helpText: i18n.DATE_MATH_HELP_TEXT,
|
||||
validations: [
|
||||
{
|
||||
validator: dateMathValidator,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TO_FIELD_CONFIG: FieldConfig<string> = {
|
||||
label: i18n.TO_FIELD_LABEL,
|
||||
helpText: i18n.DATE_MATH_HELP_TEXT,
|
||||
validations: [
|
||||
{
|
||||
validator: dateMathValidator,
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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 React from 'react';
|
||||
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
|
||||
import { type FormData } from '../../../../../../../../shared_imports';
|
||||
import { RuleFieldEditFormWrapper } from '../../../field_final_side';
|
||||
import { FullRuleScheduleAdapter } from './full_rule_schedule_adapter';
|
||||
|
||||
export function FullRuleScheduleForm(): JSX.Element {
|
||||
return (
|
||||
<RuleFieldEditFormWrapper
|
||||
component={FullRuleScheduleAdapter}
|
||||
serializer={serializer}
|
||||
deserializer={deserializer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function deserializer(_: unknown, finalRule: DiffableRule): RuleSchedule {
|
||||
return {
|
||||
interval: finalRule.rule_schedule.interval,
|
||||
from: finalRule.rule_schedule.from,
|
||||
to: finalRule.rule_schedule.to,
|
||||
};
|
||||
}
|
||||
|
||||
function serializer(formData: FormData): {
|
||||
rule_schedule: RuleSchedule;
|
||||
} {
|
||||
return {
|
||||
rule_schedule: {
|
||||
interval: formData.interval,
|
||||
from: formData.from,
|
||||
to: formData.to,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 './rule_schedule_form';
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import { SimpleRuleScheduleForm } from './simple_rule_schedule_form';
|
||||
import { useFieldUpgradeContext } from '../../../rule_upgrade/field_upgrade_context';
|
||||
import { FullRuleScheduleForm } from './full_rule_schedule_form';
|
||||
|
||||
export function RuleScheduleForm(): JSX.Element {
|
||||
const { fieldName, finalDiffableRule } = useFieldUpgradeContext();
|
||||
const canBeSimplified = useMemo(
|
||||
() => Boolean(toSimpleRuleSchedule(finalDiffableRule[fieldName] as RuleSchedule)),
|
||||
[fieldName, finalDiffableRule]
|
||||
);
|
||||
|
||||
return canBeSimplified ? <SimpleRuleScheduleForm /> : <FullRuleScheduleForm />;
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { FieldConfig } from '../../../../../../../../shared_imports';
|
||||
import { UseField } from '../../../../../../../../shared_imports';
|
||||
import { ScheduleItemField } from '../../../../../../../rule_creation/components/schedule_item_field';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export function SimpleRuleScheduleAdapter(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<UseField
|
||||
path="interval"
|
||||
config={INTERVAL_FIELD_CONFIG}
|
||||
component={ScheduleItemField}
|
||||
componentProps={INTERVAL_COMPONENT_PROPS}
|
||||
/>
|
||||
<UseField
|
||||
path="lookback"
|
||||
config={LOOK_BACK_FIELD_CONFIG}
|
||||
component={ScheduleItemField}
|
||||
componentProps={LOOKBACK_COMPONENT_PROPS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const INTERVAL_COMPONENT_PROPS = {
|
||||
minValue: 1,
|
||||
};
|
||||
|
||||
const LOOKBACK_COMPONENT_PROPS = {
|
||||
minValue: 0,
|
||||
};
|
||||
|
||||
const INTERVAL_FIELD_CONFIG: FieldConfig<string> = {
|
||||
label: i18n.INTERVAL_FIELD_LABEL,
|
||||
helpText: i18n.INTERVAL_FIELD_HELP_TEXT,
|
||||
};
|
||||
|
||||
const LOOK_BACK_FIELD_CONFIG: FieldConfig<string> = {
|
||||
label: i18n.LOOK_BACK_FIELD_LABEL,
|
||||
helpText: i18n.LOOK_BACK_FIELD_HELP_TEXT,
|
||||
};
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
|
||||
import type {
|
||||
RuleSchedule,
|
||||
SimpleRuleSchedule,
|
||||
} from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import { type FormData } from '../../../../../../../../shared_imports';
|
||||
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
|
||||
import { RuleFieldEditFormWrapper } from '../../../field_final_side';
|
||||
import { SimpleRuleScheduleAdapter } from './simple_rule_schedule_adapter';
|
||||
import { invariant } from '../../../../../../../../../common/utils/invariant';
|
||||
|
||||
export function SimpleRuleScheduleForm(): JSX.Element {
|
||||
return (
|
||||
<RuleFieldEditFormWrapper
|
||||
component={SimpleRuleScheduleAdapter}
|
||||
serializer={serializer}
|
||||
deserializer={deserializer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function deserializer(_: unknown, finalRule: DiffableRule): Partial<SimpleRuleSchedule> {
|
||||
const simpleRuleSchedule = toSimpleRuleSchedule(finalRule.rule_schedule);
|
||||
|
||||
invariant(simpleRuleSchedule, 'Unable to calculate simple rule schedule');
|
||||
|
||||
return {
|
||||
interval: simpleRuleSchedule.interval,
|
||||
lookback: simpleRuleSchedule.lookback,
|
||||
};
|
||||
}
|
||||
|
||||
function serializer(formData: FormData): {
|
||||
rule_schedule: RuleSchedule;
|
||||
} {
|
||||
const interval = TimeDuration.parse(formData.interval);
|
||||
const lookBack = TimeDuration.parse(formData.lookback);
|
||||
|
||||
invariant(interval !== undefined && interval.value > 0, 'Rule interval is invalid');
|
||||
invariant(lookBack !== undefined && lookBack.value >= 0, "Rule's look-back is invalid");
|
||||
|
||||
const fromOffsetMs = interval.toMilliseconds() + lookBack.toMilliseconds();
|
||||
const fromOffset = TimeDuration.fromMilliseconds(fromOffsetMs);
|
||||
|
||||
const from = `now-${fromOffset}`;
|
||||
|
||||
return {
|
||||
rule_schedule: {
|
||||
interval: formData.interval,
|
||||
from,
|
||||
to: 'now',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const INTERVAL_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.interval.label',
|
||||
{
|
||||
defaultMessage: 'Runs every',
|
||||
}
|
||||
);
|
||||
|
||||
export const INTERVAL_FIELD_HELP_TEXT = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.interval.helpText',
|
||||
{
|
||||
defaultMessage: 'Rules run periodically and detect alerts within the specified time frame.',
|
||||
}
|
||||
);
|
||||
|
||||
export const LOOK_BACK_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.lookback.label',
|
||||
{
|
||||
defaultMessage: 'Additional look-back time',
|
||||
}
|
||||
);
|
||||
|
||||
export const LOOK_BACK_FIELD_HELP_TEXT = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.lookback.helpText',
|
||||
{
|
||||
defaultMessage: 'Adds time to the look-back period to prevent missed alerts.',
|
||||
}
|
||||
);
|
||||
|
||||
export const FROM_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.from.label',
|
||||
{
|
||||
defaultMessage: 'From',
|
||||
}
|
||||
);
|
||||
|
||||
export const TO_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.to.label',
|
||||
{
|
||||
defaultMessage: 'To',
|
||||
}
|
||||
);
|
||||
|
||||
export const DATE_MATH_HELP_TEXT = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.fields.from.helpText',
|
||||
{
|
||||
defaultMessage: 'Date math expression, e.g. "now", "now-3d", "now+2m".',
|
||||
}
|
||||
);
|
|
@ -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 dateMath from '@kbn/datemath';
|
||||
import { type FormData, type ValidationFunc } from '../../../../../../../../../shared_imports';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const dateMathValidator: ValidationFunc<FormData, string, string> = (data) => {
|
||||
const { path, value } = data;
|
||||
|
||||
if (!dateMath.parse(value)) {
|
||||
return { code: 'ERR_DATE_MATH_INVALID', path, message: i18n.INVALID_DATE_MATH };
|
||||
}
|
||||
};
|
|
@ -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 INVALID_DATE_MATH = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleManagement.validation.dateMath.invalid',
|
||||
{
|
||||
defaultMessage: 'Date math is invalid. Valid examples: "now", "now-3h", "now+2m".',
|
||||
}
|
||||
);
|
|
@ -17,7 +17,8 @@ export const Default = () => (
|
|||
<RuleScheduleReadOnly
|
||||
ruleSchedule={{
|
||||
interval: '5m',
|
||||
lookback: '60s',
|
||||
from: 'now-360s',
|
||||
to: 'now',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -5,21 +5,36 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiDescriptionList } from '@elastic/eui';
|
||||
import { parseDuration } from '@kbn/alerting-plugin/common';
|
||||
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import * as i18n from '../../../../translations';
|
||||
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine';
|
||||
import { AccessibleTimeValue } from '../../../../rule_schedule_section';
|
||||
import { secondsToDurationString } from '../../../../../../../../detections/pages/detection_engine/rules/helpers';
|
||||
|
||||
interface RuleScheduleReadOnlyProps {
|
||||
ruleSchedule: RuleSchedule;
|
||||
}
|
||||
|
||||
export function RuleScheduleReadOnly({ ruleSchedule }: RuleScheduleReadOnlyProps) {
|
||||
const lookbackSeconds = parseDuration(ruleSchedule.lookback) / 1000;
|
||||
const lookbackHumanized = secondsToDurationString(lookbackSeconds);
|
||||
const simpleRuleSchedule = useMemo(() => toSimpleRuleSchedule(ruleSchedule), [ruleSchedule]);
|
||||
|
||||
if (simpleRuleSchedule) {
|
||||
return (
|
||||
<EuiDescriptionList
|
||||
listItems={[
|
||||
{
|
||||
title: i18n.INTERVAL_FIELD_LABEL,
|
||||
description: <AccessibleTimeValue timeValue={simpleRuleSchedule.interval} />,
|
||||
},
|
||||
{
|
||||
title: i18n.LOOK_BACK_FIELD_LABEL,
|
||||
description: <AccessibleTimeValue timeValue={simpleRuleSchedule.lookback} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiDescriptionList
|
||||
|
@ -29,8 +44,10 @@ export function RuleScheduleReadOnly({ ruleSchedule }: RuleScheduleReadOnlyProps
|
|||
description: <AccessibleTimeValue timeValue={ruleSchedule.interval} />,
|
||||
},
|
||||
{
|
||||
title: i18n.FROM_FIELD_LABEL,
|
||||
description: <AccessibleTimeValue timeValue={lookbackHumanized} />,
|
||||
title: i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL,
|
||||
description: (
|
||||
<span>{i18n.RULE_SOURCE_EVENTS_TIME_RANGE(ruleSchedule.from, ruleSchedule.to)}</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -139,7 +139,8 @@ const commonDiffableRuleFields: DiffableCommonFields = {
|
|||
required_fields: [],
|
||||
rule_schedule: {
|
||||
interval: '5m',
|
||||
lookback: '360s',
|
||||
from: 'now-660s',
|
||||
to: 'now',
|
||||
},
|
||||
max_signals: DEFAULT_MAX_SIGNALS,
|
||||
};
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
export const RULE_DETAILS_FLYOUT_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleDetails.label',
|
||||
|
@ -386,13 +388,31 @@ export const INTERVAL_FIELD_LABEL = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const FROM_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleDetails.fromFieldLabel',
|
||||
export const LOOK_BACK_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleDetails.lookBackFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Additional look-back time',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleDetails.ruleSourceEventsTimeRangeFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Analyzed time range',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_SOURCE_EVENTS_TIME_RANGE = (from: string, to: string) => (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleSourceEventsTimeRange"
|
||||
defaultMessage="From {from} to {to}"
|
||||
values={{
|
||||
from: <strong>{from}</strong>,
|
||||
to: <strong>{to}</strong>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const MAX_SIGNALS_FIELD_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.ruleDetails.maxAlertsFieldLabel',
|
||||
{
|
|
@ -84,7 +84,7 @@ export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormCom
|
|||
idAria: 'bulkEditRulesScheduleIntervalSelector',
|
||||
dataTestSubj: 'bulkEditRulesScheduleIntervalSelector',
|
||||
fullWidth: true,
|
||||
minimumValue: 1,
|
||||
minValue: 1,
|
||||
}}
|
||||
/>
|
||||
<UseField
|
||||
|
@ -94,7 +94,7 @@ export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormCom
|
|||
idAria: 'bulkEditRulesScheduleLookbackSelector',
|
||||
dataTestSubj: 'bulkEditRulesScheduleLookbackSelector',
|
||||
fullWidth: true,
|
||||
minimumValue: 1,
|
||||
minValue: 1,
|
||||
}}
|
||||
/>
|
||||
</BulkEditFormWrapper>
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
getStepsData,
|
||||
getAboutStepsData,
|
||||
getActionsStepsData,
|
||||
getHumanizedDuration,
|
||||
getModifiedAboutDetailsData,
|
||||
getPrePackagedTimelineInstallationStatus,
|
||||
determineDetailsValue,
|
||||
|
@ -328,50 +327,6 @@ describe('rule helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getHumanizedDuration', () => {
|
||||
test('returns from as seconds if from duration is specified in seconds', () => {
|
||||
const result = getHumanizedDuration('now-62s', '1m');
|
||||
|
||||
expect(result).toEqual('2s');
|
||||
});
|
||||
|
||||
test('returns from as seconds if from duration is specified in seconds greater than 60', () => {
|
||||
const result = getHumanizedDuration('now-122s', '1m');
|
||||
|
||||
expect(result).toEqual('62s');
|
||||
});
|
||||
|
||||
test('returns from as minutes if from duration is specified in minutes', () => {
|
||||
const result = getHumanizedDuration('now-660s', '5m');
|
||||
|
||||
expect(result).toEqual('6m');
|
||||
});
|
||||
|
||||
test('returns from as minutes if from duration is specified in minutes greater than 60', () => {
|
||||
const result = getHumanizedDuration('now-6600s', '5m');
|
||||
|
||||
expect(result).toEqual('105m');
|
||||
});
|
||||
|
||||
test('returns from as hours if from duration is specified in hours', () => {
|
||||
const result = getHumanizedDuration('now-7500s', '5m');
|
||||
|
||||
expect(result).toEqual('2h');
|
||||
});
|
||||
|
||||
test('returns from as if from is not parsable as dateMath', () => {
|
||||
const result = getHumanizedDuration('randomstring', '5m');
|
||||
|
||||
expect(result).toEqual('NaNs');
|
||||
});
|
||||
|
||||
test('returns from as 5m if interval is not parsable as dateMath', () => {
|
||||
const result = getHumanizedDuration('now-300s', 'randomstring');
|
||||
|
||||
expect(result).toEqual('5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScheduleStepsData', () => {
|
||||
test('returns expected ScheduleStep rule object', () => {
|
||||
const mockedRule = {
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import dateMath from '@kbn/datemath';
|
||||
import moment from 'moment';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
|
@ -22,6 +20,7 @@ import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
|
|||
import type { Filter } from '@kbn/es-query';
|
||||
import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { requiredOptional } from '@kbn/zod-helpers';
|
||||
import { toSimpleRuleSchedule } from '../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
|
||||
import {
|
||||
ALERT_SUPPRESSION_FIELDS_FIELD_NAME,
|
||||
ALERT_SUPPRESSION_DURATION_TYPE_FIELD_NAME,
|
||||
|
@ -191,45 +190,23 @@ export const getDefineStepsData = (rule: RuleResponse): DefineStepRule => ({
|
|||
});
|
||||
|
||||
export const getScheduleStepsData = (rule: RuleResponse): ScheduleStepRule => {
|
||||
const { interval, from } = rule;
|
||||
const fromHumanizedValue = getHumanizedDuration(from, interval);
|
||||
const simpleRuleSchedule = toSimpleRuleSchedule(rule);
|
||||
|
||||
if (simpleRuleSchedule) {
|
||||
return {
|
||||
interval: simpleRuleSchedule.interval,
|
||||
from: simpleRuleSchedule.lookback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
interval,
|
||||
from: fromHumanizedValue,
|
||||
interval: rule.interval,
|
||||
// Fallback to zero look-back since UI isn't able to handle negative
|
||||
// look-back
|
||||
from: '0s',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts seconds to duration string, like "1h", "30m" or "15s"
|
||||
*/
|
||||
export const secondsToDurationString = (seconds: number): string => {
|
||||
if (seconds === 0) {
|
||||
return `0s`;
|
||||
}
|
||||
|
||||
if (seconds % 3600 === 0) {
|
||||
return `${seconds / 3600}h`;
|
||||
} else if (seconds % 60 === 0) {
|
||||
return `${seconds / 60}m`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getHumanizedDuration = (from: string, interval: string): string => {
|
||||
const fromValue = dateMath.parse(from) ?? moment();
|
||||
const intervalValue = dateMath.parse(`now-${interval}`) ?? moment();
|
||||
|
||||
const fromDuration = moment.duration(intervalValue.diff(fromValue));
|
||||
|
||||
// Basing calculations off floored seconds count as moment durations weren't precise
|
||||
const intervalDuration = Math.floor(fromDuration.asSeconds());
|
||||
// For consistency of display value
|
||||
|
||||
return secondsToDurationString(intervalDuration);
|
||||
};
|
||||
|
||||
export const getAboutStepsData = (rule: RuleResponse, detailsView: boolean): AboutStepRule => {
|
||||
const { name, description, note, setup } = determineDetailsValue(rule, detailsView);
|
||||
const {
|
||||
|
|
|
@ -8,8 +8,23 @@
|
|||
import { transformDiffableFieldValues } from './diffable_rule_fields_mappings';
|
||||
|
||||
describe('transformDiffableFieldValues', () => {
|
||||
it('transforms rule_schedule into "from" value', () => {
|
||||
const result = transformDiffableFieldValues('from', { interval: '5m', lookback: '4m' });
|
||||
expect(result).toEqual({ type: 'TRANSFORMED_FIELD', value: 'now-540s' });
|
||||
it('does NOT transform "from" in rule_schedule', () => {
|
||||
const result = transformDiffableFieldValues('from', {
|
||||
interval: '5m',
|
||||
from: 'now-10m',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ type: 'NON_TRANSFORMED_FIELD' });
|
||||
});
|
||||
|
||||
it('does NOT transform "to" in rule_schedule', () => {
|
||||
const result = transformDiffableFieldValues('to', {
|
||||
interval: '5m',
|
||||
from: 'now-10m',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ type: 'NON_TRANSFORMED_FIELD' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { get, has } from 'lodash';
|
||||
import type { RuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
|
||||
import type {
|
||||
RuleSchedule,
|
||||
DataSourceIndexPatterns,
|
||||
DataSourceDataView,
|
||||
InlineKqlQuery,
|
||||
|
@ -15,7 +15,6 @@ import type {
|
|||
} from '../../../../../../common/api/detection_engine';
|
||||
import { type AllFieldsDiff } from '../../../../../../common/api/detection_engine';
|
||||
import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset';
|
||||
import { calculateFromValue } from '../../../rule_types/utils/utils';
|
||||
|
||||
/**
|
||||
* Retrieves and transforms the value for a specific field from a DiffableRule group.
|
||||
|
@ -32,11 +31,6 @@ import { calculateFromValue } from '../../../rule_types/utils/utils';
|
|||
* mapDiffableRuleFieldValueToRuleSchema('index', { index_patterns: ['logs-*'] })
|
||||
* // Returns: ['logs-*']
|
||||
*
|
||||
* @example
|
||||
* // For a 'from' field in a rule schedule
|
||||
* mapDiffableRuleFieldValueToRuleSchema('from', { interval: '5d', lookback: '30d' })
|
||||
* // Returns: 'now-30d'
|
||||
*
|
||||
*/
|
||||
export const mapDiffableRuleFieldValueToRuleSchemaFormat = (
|
||||
fieldName: keyof PrebuiltRuleAsset,
|
||||
|
@ -142,8 +136,8 @@ const SUBFIELD_MAPPING: Record<string, string> = {
|
|||
timeline_id: 'timeline_id',
|
||||
timeline_title: 'timeline_title',
|
||||
interval: 'interval',
|
||||
from: 'lookback',
|
||||
to: 'lookback',
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -157,10 +151,6 @@ const SUBFIELD_MAPPING: Record<string, string> = {
|
|||
* mapRuleFieldToDiffableRuleSubfield('index')
|
||||
* // Returns: 'index_patterns'
|
||||
*
|
||||
* @example
|
||||
* mapRuleFieldToDiffableRuleSubfield('from')
|
||||
* // Returns: 'lookback'
|
||||
*
|
||||
*/
|
||||
export function mapRuleFieldToDiffableRuleSubfield(fieldName: string): string {
|
||||
return SUBFIELD_MAPPING[fieldName] || fieldName;
|
||||
|
@ -190,11 +180,6 @@ type TransformValuesReturnType =
|
|||
* - If not transformed: { type: 'NON_TRANSFORMED_FIELD' }
|
||||
*
|
||||
* @example
|
||||
* // Transforms 'from' field
|
||||
* transformDiffableFieldValues('from', { lookback: '30d' })
|
||||
* // Returns: { type: 'TRANSFORMED_FIELD', value: 'now-30d' }
|
||||
*
|
||||
* @example
|
||||
* // Transforms 'saved_id' field for inline queries
|
||||
* transformDiffableFieldValues('saved_id', { type: 'inline_query', ... })
|
||||
* // Returns: { type: 'TRANSFORMED_FIELD', value: undefined }
|
||||
|
@ -204,12 +189,7 @@ export const transformDiffableFieldValues = (
|
|||
fieldName: string,
|
||||
diffableFieldValue: RuleSchedule | InlineKqlQuery | unknown
|
||||
): TransformValuesReturnType => {
|
||||
if (fieldName === 'from' && isRuleSchedule(diffableFieldValue)) {
|
||||
const from = calculateFromValue(diffableFieldValue.interval, diffableFieldValue.lookback);
|
||||
return { type: 'TRANSFORMED_FIELD', value: from };
|
||||
} else if (fieldName === 'to') {
|
||||
return { type: 'TRANSFORMED_FIELD', value: `now` };
|
||||
} else if (fieldName === 'saved_id' && isInlineQuery(diffableFieldValue)) {
|
||||
if (fieldName === 'saved_id' && isInlineQuery(diffableFieldValue)) {
|
||||
// saved_id should be set only for rules with SavedKqlQuery, undefined otherwise
|
||||
return { type: 'TRANSFORMED_FIELD', value: undefined };
|
||||
} else if (fieldName === 'data_view_id' && isDataSourceIndexPatterns(diffableFieldValue)) {
|
||||
|
@ -221,10 +201,6 @@ export const transformDiffableFieldValues = (
|
|||
return { type: 'NON_TRANSFORMED_FIELD' };
|
||||
};
|
||||
|
||||
function isRuleSchedule(value: unknown): value is RuleSchedule {
|
||||
return typeof value === 'object' && value !== null && 'lookback' in value;
|
||||
}
|
||||
|
||||
function isInlineQuery(value: unknown): value is InlineKqlQuery {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && 'type' in value && value.type === 'inline_query'
|
||||
|
|
|
@ -269,8 +269,8 @@ export const TIMELINE_TEMPLATE_VALUE = '[data-test-subj="timelineTemplatePropert
|
|||
export const INTERVAL_TITLE = '[data-test-subj="intervalPropertyTitle"]';
|
||||
export const INTERVAL_VALUE = '[data-test-subj="intervalPropertyValue"]';
|
||||
|
||||
export const FROM_TITLE = '[data-test-subj="fromPropertyTitle"]';
|
||||
export const FROM_VALUE = '[data-test-subj^="fromPropertyValue"]';
|
||||
export const LOOK_BACK_TITLE = '[data-test-subj="lookBackPropertyTitle"]';
|
||||
export const LOOK_BACK_VALUE = '[data-test-subj^="lookBackPropertyValue"]';
|
||||
|
||||
export const INDEX_TITLE = '[data-test-subj="indexPropertyTitle"]';
|
||||
export const INDEX_VALUE_ITEM = '[data-test-subj="indexPropertyValueItem"]';
|
||||
|
|
|
@ -14,6 +14,8 @@ import {
|
|||
} from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import type { PrebuiltRuleAsset } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules';
|
||||
import { calcDateMathDiff } from '@kbn/securitysolution-utils/date_math';
|
||||
import { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
|
||||
import {
|
||||
ALERT_SUPPRESSION_DURATION_TITLE,
|
||||
ALERT_SUPPRESSION_DURATION_VALUE,
|
||||
|
@ -42,8 +44,8 @@ import {
|
|||
FILTERS_TITLE,
|
||||
FILTERS_VALUE_ITEM,
|
||||
FLYOUT_CLOSE_BTN,
|
||||
FROM_TITLE,
|
||||
FROM_VALUE,
|
||||
LOOK_BACK_TITLE,
|
||||
LOOK_BACK_VALUE,
|
||||
INDEX_TITLE,
|
||||
INDEX_VALUE_ITEM,
|
||||
INSTALL_PREBUILT_RULE_PREVIEW,
|
||||
|
@ -227,8 +229,15 @@ export const assertCommonPropertiesShown = (properties: Partial<PrebuiltRuleAsse
|
|||
cy.get(INTERVAL_TITLE).should('have.text', 'Runs every');
|
||||
cy.get(INTERVAL_VALUE).should('contain.text', properties.interval);
|
||||
|
||||
cy.get(FROM_TITLE).should('have.text', 'Additional look-back time');
|
||||
cy.get(FROM_VALUE).invoke('attr', 'data-test-subj').should('contain', properties.from);
|
||||
cy.get(LOOK_BACK_TITLE).should('have.text', 'Additional look-back time');
|
||||
cy.get(LOOK_BACK_VALUE)
|
||||
.invoke('attr', 'data-test-subj')
|
||||
.should(
|
||||
'contain',
|
||||
TimeDuration.fromMilliseconds(
|
||||
calcDateMathDiff(properties.from ?? 'now-6m', `now-${properties.interval}`) ?? 0
|
||||
).toString()
|
||||
);
|
||||
};
|
||||
|
||||
export const assertIndexPropertyShown = (index: string[]) => {
|
||||
|
|
|
@ -46,5 +46,6 @@
|
|||
"@kbn/elastic-assistant-common",
|
||||
"@kbn/cloud-security-posture-common",
|
||||
"@kbn/security-plugin-types-common",
|
||||
"@kbn/securitysolution-utils"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue