From 30bb71a516cf0e8e83caab99f9119057a3b1bc82 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Mon, 20 Jan 2025 14:41:23 +0100 Subject: [PATCH] [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` image ### 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) Screenshot 2025-01-02 at 13 16 59 Screenshot 2025-01-02 at 13 17 20 Screenshot 2025-01-02 at 13 18 24 - Rule upgrade workflow (positive look-back) Screenshot 2025-01-02 at 13 19 12 Screenshot 2025-01-02 at 13 25 31 - JSON view Screenshot 2025-01-02 at 13 31 37 - Rule details page Screenshot 2025-01-02 at 13 13 16 Screenshot 2025-01-02 at 13 14 10 ## 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 --- .github/CODEOWNERS | 2 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../kbn-securitysolution-utils/date_math.ts | 8 + .../kbn-securitysolution-utils/kibana.jsonc | 6 +- .../src/date_math/calc_date_math_diff.test.ts | 43 ++ .../src/date_math/calc_date_math_diff.ts | 25 ++ .../src/date_math/index.ts | 9 + .../src/date_math/normalize_date_math.test.ts | 29 ++ .../src/date_math/normalize_date_math.ts | 32 ++ .../src/time_duration/time_duration.test.ts | 229 ++++++++++ .../src/time_duration/time_duration.ts | 120 ++++++ .../time_duration.ts | 8 + .../kbn-securitysolution-utils/tsconfig.json | 16 +- .../model/rule_schema/rule_schedule.ts | 50 +++ .../to_simple_rule_schedule.test.ts | 105 +++++ .../rule_schema/to_simple_rule_schedule.ts | 32 ++ .../diffable_rule/diffable_field_types.ts | 10 - .../model/diff/diffable_rule/diffable_rule.ts | 2 +- .../diff/extract_rule_schedule.test.ts | 19 +- .../diff/extract_rule_schedule.ts | 62 +-- .../history_window_start_edit.tsx | 3 +- .../schedule_item_field.test.tsx | 65 +-- .../schedule_item_field.tsx | 102 ++--- .../components/step_schedule_rule/index.tsx | 4 +- .../pages/rule_creation/helpers.test.ts | 92 +--- .../pages/rule_creation/helpers.ts | 28 +- .../rule_details/json_diff/json_diff.test.tsx | 74 ---- ...get_field_diffs_for_grouped_fields.test.ts | 392 ++++++++++++++++++ .../get_field_diffs_for_grouped_fields.ts | 101 +++-- .../components/rule_details/rule_diff_tab.tsx | 20 +- .../rule_details/rule_schedule_section.tsx | 65 ++- .../get_subfield_changes/rule_schedule.ts | 54 ++- .../final_edit/common_rule_field_edit.tsx | 16 +- .../final_edit/fields/rule_schedule.tsx | 56 --- .../full_rule_schedule_adapter.tsx | 58 +++ .../rule_schedule/full_rule_schedule_form.tsx | 43 ++ .../final_edit/fields/rule_schedule/index.ts | 8 + .../rule_schedule/rule_schedule_form.tsx | 23 + .../simple_rule_schedule_adapter.tsx | 49 +++ .../simple_rule_schedule_form.tsx | 63 +++ .../fields/rule_schedule/translations.ts | 57 +++ .../validators/date_math_validator.ts | 18 + .../rule_schedule/validators/translations.ts | 15 + .../rule_schedule/rule_schedule.stories.tsx | 3 +- .../fields/rule_schedule/rule_schedule.tsx | 33 +- .../final_readonly/storybook/mocks.ts | 3 +- .../{translations.ts => translations.tsx} | 24 +- .../bulk_actions/forms/schedule_form.tsx | 4 +- .../detection_engine/rules/helpers.test.tsx | 45 -- .../pages/detection_engine/rules/helpers.tsx | 49 +-- .../diffable_rule_fields_mappings.test.ts | 21 +- .../diffable_rule_fields_mappings.ts | 32 +- .../cypress/screens/alerts_detection_rules.ts | 4 +- .../cypress/tasks/prebuilt_rules_preview.ts | 17 +- .../cypress/tsconfig.json | 1 + 57 files changed, 1838 insertions(+), 614 deletions(-) create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/date_math.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.test.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/index.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.test.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.test.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.ts create mode 100644 x-pack/solutions/security/packages/kbn-securitysolution-utils/time_duration.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schedule.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.test.ts delete mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_adapter.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_form.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/rule_schedule_form.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_adapter.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_form.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/translations.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/date_math_validator.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/translations.ts rename x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/{translations.ts => translations.tsx} (94%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 266b81f8c1d93..468680be6105b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 28cca16be8e87..43c5be2b14d69 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -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", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 81dc2bd001f14..5b9c7c6337a0b 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -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": "インストールして有効化", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index c6d69d18c41fd..d0a69e4a7dfae 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -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": "安装并启用", diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/date_math.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/date_math.ts new file mode 100644 index 0000000000000..fae52c1f7b8c9 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/date_math.ts @@ -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'; diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/kibana.jsonc b/x-pack/solutions/security/packages/kbn-securitysolution-utils/kibana.jsonc index 5e6ccf9096ea3..a8209fee52953 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-utils/kibana.jsonc +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/kibana.jsonc @@ -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" -} \ No newline at end of file +} diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.test.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.test.ts new file mode 100644 index 0000000000000..9cca1a0854486 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.test.ts @@ -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(); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.ts new file mode 100644 index 0000000000000..7e4c21cde1f57 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/calc_date_math_diff.ts @@ -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; +} diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/index.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/index.ts new file mode 100644 index 0000000000000..19645d895c914 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/index.ts @@ -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'; diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.test.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.test.ts new file mode 100644 index 0000000000000..72740f5063729 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.test.ts @@ -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); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.ts new file mode 100644 index 0000000000000..5e14610491bb9 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/date_math/normalize_date_math.ts @@ -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; + } +} diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.test.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.test.ts new file mode 100644 index 0000000000000..e30cae48f2f90 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.ts new file mode 100644 index 0000000000000..8fcb23cecd8e9 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/src/time_duration/time_duration.ts @@ -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('|')})$`); diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/time_duration.ts b/x-pack/solutions/security/packages/kbn-securitysolution-utils/time_duration.ts new file mode 100644 index 0000000000000..61905da7f7d8d --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/time_duration.ts @@ -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'; diff --git a/x-pack/solutions/security/packages/kbn-securitysolution-utils/tsconfig.json b/x-pack/solutions/security/packages/kbn-securitysolution-utils/tsconfig.json index 063735a114dad..fc75ff2e16696 100644 --- a/x-pack/solutions/security/packages/kbn-securitysolution-utils/tsconfig.json +++ b/x-pack/solutions/security/packages/kbn-securitysolution-utils/tsconfig.json @@ -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/**/*"] } diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schedule.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schedule.ts new file mode 100644 index 0000000000000..0177dd0b96db3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schedule.ts @@ -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; +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; +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'] }), +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.test.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.test.ts new file mode 100644 index 0000000000000..c2383090a466a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.test.ts @@ -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(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.ts new file mode 100644 index 0000000000000..5227099e63111 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/to_simple_rule_schedule.ts @@ -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(), + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts index 6262fd0e579e7..c41e950dab0ea 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_field_types.ts @@ -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; -export const RuleSchedule = z.object({ - interval: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }), - lookback: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }), -}); - // ------------------------------------------------------------------------------------------------- // Rule name override diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts index 428d30495722a..a7fd0d53078bb 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts @@ -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; export const DiffableCommonFields = z.object({ diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts index 7c03aae9a012c..7781041efedc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts @@ -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' }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts index 7a128c24492ab..25a4fe81c3e3d 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts @@ -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), + }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/history_window_start_edit/history_window_start_edit.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/history_window_start_edit/history_window_start_edit.tsx index c876fe926ce4f..ab0a06c196d9e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/history_window_start_edit/history_window_start_edit.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/history_window_start_edit/history_window_start_edit.tsx @@ -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 { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.test.tsx index bf019bd43f2fd..211593652fc6d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.test.tsx @@ -15,27 +15,17 @@ describe('ScheduleItemField', () => { it('renders correctly', () => { const mockField = useFormFieldMock(); const wrapper = shallow( - + ); 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(); const wrapper = mount( - + ); @@ -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(); const wrapper = mount( ); @@ -67,21 +60,23 @@ 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', () => { - const unsafeInput = 'this is not a number'; - + it.each([ + [-5, -10], + [5, 0], + [10, 5], + [90, 60], + ])('saturates a value "%s" greater than maxValue', (unsafeInput, expected) => { const mockField = useFormFieldMock(); const wrapper = mount( ); @@ -91,6 +86,24 @@ describe('ScheduleItemField', () => { .last() .simulate('change', { target: { value: unsafeInput } }); - expect(mockField.setValue).toHaveBeenCalledWith('0s'); + expect(mockField.setValue).toHaveBeenCalledWith(`${expected}s`); + }); + + it('skips updating a non-numeric values', () => { + const unsafeInput = 'this is not a number'; + + const mockField = useFormFieldMock(); + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="interval"]') + .last() + .simulate('change', { target: { value: unsafeInput } }); + + expect(mockField.setValue).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.tsx index 241e3869958a8..2e0f3739ff59a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation/components/schedule_item_field/schedule_item_field.tsx @@ -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; 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(0); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -100,38 +92,40 @@ export const ScheduleItemField = ({ const onChangeTimeVal = useCallback>( (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( () => ( @@ -161,21 +155,27 @@ export const ScheduleItemField = ({ 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} /> ); -}; +} + +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)); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_schedule_rule/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_schedule_rule/index.tsx index 7c309ed32a68b..be50ef8432a48 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_schedule_rule/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_schedule_rule/index.tsx @@ -48,7 +48,7 @@ const StepScheduleRuleComponent: FC = ({ idAria: 'detectionEngineStepScheduleRuleInterval', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleInterval', - minimumValue: 1, + minValue: 1, }} /> = ({ idAria: 'detectionEngineStepScheduleRuleFrom', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleFrom', - minimumValue: 1, + minValue: 0, }} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts index d28634fb6691a..15acb67c63c19 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts @@ -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); + 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); + expect(result).toMatchObject(mockStepData); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index 4610e26321797..4872f1ace4936 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -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 = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/json_diff.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/json_diff.test.tsx index 58ee6b56528a5..1c8d7a65fa9d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/json_diff.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/json_diff.test.tsx @@ -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( - - ); - expect(findDiffLineContaining('"from":')).toBeNull(); - - rerender( - - ); - expect(findDiffLineContaining('"from":')).toBeNull(); - - rerender( - - ); - expect(findDiffLineContaining('"from":')).toBeNull(); - - /* Semantically different durations should generate diff */ - rerender( - - ); - expect(findDiffLineContaining('- "from": "now-7260s",+ "from": "now-7200s",')).not.toBeNull(); - - /* NOTE - Investigation guide */ - rerender(); - expect(findDiffLineContaining('"note":')).toBeNull(); - - rerender( - - ); - expect(findDiffLineContaining('"note":')).toBeNull(); - - rerender(); - expect(findDiffLineContaining('"note":')).toBeNull(); - - rerender(); - expect(findDiffLineContaining('- "note": "",+ "note": "abc",')).not.toBeNull(); - }); - it('Unchanged sections of a rule should be hidden by default', async () => { const oldRule: RuleResponse = { ...savedRuleMock, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.test.ts new file mode 100644 index 0000000000000..b2a3c6e75cf51 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.test.ts @@ -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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + expect(result).toEqual([]); + }); + + it('returns empty array for undefined versions', () => { + const result = getFieldDiffsForRuleSchedule({ + current_version: undefined, + target_version: undefined, + } as ThreeWayDiff); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts index 21717998483ea..7dffd3a593b15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_field_diffs_for_grouped_fields.ts @@ -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 ): 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 = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx index c2bf9ffa29098..010c7e4aa4ad4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx @@ -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 = [ @@ -79,20 +75,6 @@ const sortAndStringifyJson = (jsObject: Record): 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 diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx index 5ed99e4328136..45b7eb3f5ecf0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx @@ -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) => ( ); -interface FromProps { - from: string; - interval: string; +interface LookBackProps { + value: string; } -const From = ({ from, interval }: FromProps) => ( - +const LookBack = ({ value }: LookBackProps) => ( + ); export interface RuleScheduleSectionProps extends React.ComponentProps { @@ -62,18 +59,46 @@ export const RuleScheduleSection = ({ return null; } - const ruleSectionListItems = []; + const to = rule.to ?? 'now'; - ruleSectionListItems.push( - { - title: {i18n.INTERVAL_FIELD_LABEL}, - description: , - }, - { - title: {i18n.FROM_FIELD_LABEL}, - description: , - } - ); + const simpleRuleSchedule = toSimpleRuleSchedule({ + interval: rule.interval, + from: rule.from, + to, + }); + + const ruleSectionListItems = !simpleRuleSchedule + ? [ + { + title: {i18n.INTERVAL_FIELD_LABEL}, + description: , + }, + { + title: ( + + {i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL} + + ), + description: ( + + {i18n.RULE_SOURCE_EVENTS_TIME_RANGE( + normalizeDateMath(rule.from), + normalizeDateMath(to) + )} + + ), + }, + ] + : [ + { + title: {i18n.INTERVAL_FIELD_LABEL}, + description: , + }, + { + title: {i18n.LOOK_BACK_FIELD_LABEL}, + description: , + }, + ]; return (
diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/rule_schedule.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/rule_schedule.ts index 8bbf0c9235257..2f15eff892752 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/rule_schedule.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/get_subfield_changes/rule_schedule.ts @@ -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: '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), + oldSubfieldValue: stringifyToSortedJson(oldFieldValue.lookback), + newSubfieldValue: stringifyToSortedJson(newFieldValue.lookback), }); } return changes; -}; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/common_rule_field_edit.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/common_rule_field_edit.tsx index 0b8668e90a903..31752fd15b4c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/common_rule_field_edit.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/common_rule_field_edit.tsx @@ -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 ( - - ); + return ; case 'setup': return ; case 'severity': diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule.tsx deleted file mode 100644 index 3acd7c3050fbb..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule.tsx +++ /dev/null @@ -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 ( - <> - - - - ); -} - -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, - }, - }; -} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_adapter.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_adapter.tsx new file mode 100644 index 0000000000000..38e86651111c7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_adapter.tsx @@ -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 ( + <> + + + + + ); +} + +const INTERVAL_COMPONENT_PROPS = { + minValue: 1, +}; + +const INTERVAL_FIELD_CONFIG: FieldConfig = { + label: i18n.INTERVAL_FIELD_LABEL, + helpText: i18n.INTERVAL_FIELD_HELP_TEXT, +}; + +const FROM_FIELD_CONFIG: FieldConfig = { + label: i18n.FROM_FIELD_LABEL, + helpText: i18n.DATE_MATH_HELP_TEXT, + validations: [ + { + validator: dateMathValidator, + }, + ], +}; + +const TO_FIELD_CONFIG: FieldConfig = { + label: i18n.TO_FIELD_LABEL, + helpText: i18n.DATE_MATH_HELP_TEXT, + validations: [ + { + validator: dateMathValidator, + }, + ], +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_form.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_form.tsx new file mode 100644 index 0000000000000..4b42c99fc40bb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/full_rule_schedule_form.tsx @@ -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 ( + + ); +} + +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, + }, + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/index.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/index.ts new file mode 100644 index 0000000000000..ce1f82adc599e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/index.ts @@ -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'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/rule_schedule_form.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/rule_schedule_form.tsx new file mode 100644 index 0000000000000..ca9cb0db316f1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/rule_schedule_form.tsx @@ -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 ? : ; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_adapter.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_adapter.tsx new file mode 100644 index 0000000000000..a7bc6425f3845 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_adapter.tsx @@ -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 ( + <> + + + + ); +} + +const INTERVAL_COMPONENT_PROPS = { + minValue: 1, +}; + +const LOOKBACK_COMPONENT_PROPS = { + minValue: 0, +}; + +const INTERVAL_FIELD_CONFIG: FieldConfig = { + label: i18n.INTERVAL_FIELD_LABEL, + helpText: i18n.INTERVAL_FIELD_HELP_TEXT, +}; + +const LOOK_BACK_FIELD_CONFIG: FieldConfig = { + label: i18n.LOOK_BACK_FIELD_LABEL, + helpText: i18n.LOOK_BACK_FIELD_HELP_TEXT, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_form.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_form.tsx new file mode 100644 index 0000000000000..28f995dd9afde --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/simple_rule_schedule_form.tsx @@ -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 ( + + ); +} + +function deserializer(_: unknown, finalRule: DiffableRule): Partial { + 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', + }, + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/translations.ts new file mode 100644 index 0000000000000..e7616e54c040e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/translations.ts @@ -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".', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/date_math_validator.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/date_math_validator.ts new file mode 100644 index 0000000000000..944fa99e53de8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/date_math_validator.ts @@ -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 = (data) => { + const { path, value } = data; + + if (!dateMath.parse(value)) { + return { code: 'ERR_DATE_MATH_INVALID', path, message: i18n.INVALID_DATE_MATH }; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/translations.ts new file mode 100644 index 0000000000000..7e6d2407b9a36 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/rule_schedule/validators/translations.ts @@ -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".', + } +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.stories.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.stories.tsx index a49735a35b5be..93dc9c70cf5c8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.stories.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.stories.tsx @@ -17,7 +17,8 @@ export const Default = () => ( ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.tsx index 24d0e5d6e05f1..e9ee329cfe1be 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/fields/rule_schedule/rule_schedule.tsx @@ -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 ( + , + }, + { + title: i18n.LOOK_BACK_FIELD_LABEL, + description: , + }, + ]} + /> + ); + } return ( , }, { - title: i18n.FROM_FIELD_LABEL, - description: , + title: i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL, + description: ( + {i18n.RULE_SOURCE_EVENTS_TIME_RANGE(ruleSchedule.from, ruleSchedule.to)} + ), }, ]} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts index 18973df5ca545..4d0bcbba3041c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_readonly/storybook/mocks.ts @@ -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, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.tsx similarity index 94% rename from x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts rename to x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.tsx index 3f787b60fa427..69e113b5e112c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.tsx @@ -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) => ( + {from}, + to: {to}, + }} + /> +); + export const MAX_SIGNALS_FIELD_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDetails.maxAlertsFieldLabel', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/schedule_form.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/schedule_form.tsx index 518c18a59a413..4045d9a7e7878 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/schedule_form.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/forms/schedule_form.tsx @@ -84,7 +84,7 @@ export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormCom idAria: 'bulkEditRulesScheduleIntervalSelector', dataTestSubj: 'bulkEditRulesScheduleIntervalSelector', fullWidth: true, - minimumValue: 1, + minValue: 1, }} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 210884f9754a1..2152d10115b2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -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 = { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 4136b21e96106..1dff767667450 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -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,43 +190,21 @@ 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); - return { - interval, - from: fromHumanizedValue, - }; -}; - -/** - * 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`; + if (simpleRuleSchedule) { + return { + interval: simpleRuleSchedule.interval, + from: simpleRuleSchedule.lookback, + }; } -}; - -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); + return { + interval: rule.interval, + // Fallback to zero look-back since UI isn't able to handle negative + // look-back + from: '0s', + }; }; export const getAboutStepsData = (rule: RuleResponse, detailsView: boolean): AboutStepRule => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.test.ts index 95495ebea0d9d..ce628b0d07b35 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.test.ts @@ -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' }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts index 86ba3e5dd6bee..32ba541dcd3b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/perform_rule_upgrade/diffable_rule_fields_mappings.ts @@ -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 = { 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 = { * 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' diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts index e2ce41dee2847..f6f1bae56bfce 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts_detection_rules.ts @@ -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"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/prebuilt_rules_preview.ts b/x-pack/test/security_solution_cypress/cypress/tasks/prebuilt_rules_preview.ts index c67ab29831075..9ae6c623a388f 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/prebuilt_rules_preview.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/prebuilt_rules_preview.ts @@ -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 { diff --git a/x-pack/test/security_solution_cypress/cypress/tsconfig.json b/x-pack/test/security_solution_cypress/cypress/tsconfig.json index 67f8e878bc6fe..20064862b1389 100644 --- a/x-pack/test/security_solution_cypress/cypress/tsconfig.json +++ b/x-pack/test/security_solution_cypress/cypress/tsconfig.json @@ -46,5 +46,6 @@ "@kbn/elastic-assistant-common", "@kbn/cloud-security-posture-common", "@kbn/security-plugin-types-common", + "@kbn/securitysolution-utils" ] }