[Security Solution] Handle negative lookback in rule upgrade flyout (#204317)

**Fixes: https://github.com/elastic/kibana/issues/202715**
**Fixes: https://github.com/elastic/kibana/issues/204714**

## Summary

This PR makes inconsistent/wrong rule's look-back duration prominent for
a user. It falls back to a default 1 minute value in rule upgrade
workflow.

## Details

### Negative/wrong `lookback` problem

There is a difference between rule schedule value in a saved object and
value represented to users

- Saved object (and rule management API) has `interval`, `from` and `to`
fields representing rule schedule. `interval` shows how often a rule
runs in task runner. `from` and `to` stored in date math format like
`now-10m` represent a date time range used to fetch source events. Task
manager strives to run rules exactly every `interval` but it's not
always possible due to multiple reasons like system load and various
delays. To avoid any gaps to appear `from` point in time usually stands
earlier than current time minus `interval`, for example `interval` is
`10 minutes` and `from` is `now-12m` meaning rule will analyze events
starting from 12 minutes old. `to` represents the latest point in time
source events will be analyzed.
- Diffable rule and UI represent rule schedule as `interval` and
`lookback`. Where `interval` is the same as above and `lookback` and a
time duration before current time minus `interval`. For example
`interval` is `10 minutes` and lookback is `2 minutes` it means a rule
will analyzing events starting with 12 minutes old until the current
moment in time.

Literally `interval`, `from` and `to` mean a rule runs every `interval`
and analyzes events starting from `from` until `to`. Technically `from`
and `to` may not have any correlation with `interval`, for example a
rule may analyze one year old events. While it's reasonable for manual
rule runs and gap remediation the same approach doesn't work well for
usual rule schedule. Transformation between `interval`/`from`/`to` and
`interval`/`lookback` works only when `to` is equal the current moment
in time i.e. `now`.

Rule management APIs allow to set any `from` and `to` values resulting
in inconsistent rule schedule. Transformed `interval`/`lookback` value
won't represent real time interval used to fetch source events for
analysis. On top of that negative `lookback` value may puzzle users on
the meaning of the negative sign.

### Prebuilt rules with `interval`/`from`/`to` resulting in negative
`lookback`

Some prebuilt rules have such `interval`, `from` and `to` field values
thatnegative `lookback` is expected, for example `Multiple Okta Sessions
Detected for a Single User`. It runs every `60 minutes` but has `from`
field set to `now-30m` and `to` equals `now`. In the end we have
`lookback` equals `to` - `from` - `interval` = `30 minutes` - `60
minutes` = `-30 minutes`.

Our UI doesn't handle negative `lookback` values. It simply discards a
negative sign and substitutes the rest for editing. In the case above
`30 minutes` will be suggested for editing. Saving the form will result
in changing `from` to `now-90m`

<img width="1712" alt="image"
src="https://github.com/user-attachments/assets/05519743-9562-4874-8a73-5596eeccacf2"
/>

### Changes in this PR

This PR mitigates rule schedule inconsistencies caused by `to` fields
not using the current point in time i.e. `now`. The following was done

- `DiffableRule`'s `rule_schedule` was changed to have `interval`,
`from` and `to` fields instead of `interval` and `lookback`
- `_perform` rule upgrade API endpoint was adapted to the new
`DIffableRule`'s `rule_schedule`
- Rule upgrade flyout calculates and shows `interval` and `lookback` in
Diff View, readonly view and field form when `lookback` is non-negative
and `to` equals `now`
- Rule upgrade flyout shows `interval`, `from` and `to` in Diff View,
readonly view and field form when `to` isn't equal `now` or calculated
`lookback` is negative
- Rule upgrade flyout shows a warning when `to` isn't equal `now` or
calculated `lookback` is negative
- Rule upgrade flyout's JSON Diff shows `interval` and `lookback` when
`lookback` is non-negative and `to` equals `now` and shows `interval`,
`from` and `to` in any other case
- Rule details page shows `interval`, `from` and `to` in Diff View,
readonly view and field form when `to` isn't equal `now` or calculated
`lookback` is negative
- `maxValue` was added to `ScheduleItemField` to have an ability to
restrict input at reasonable values

## Screenshots

- Rule upgrade workflow (negative look-back)

<img width="2558" alt="Screenshot 2025-01-02 at 13 16 59"
src="https://github.com/user-attachments/assets/b8bf727f-11ca-424f-892b-b024ba7f847a"
/>

<img width="2553" alt="Screenshot 2025-01-02 at 13 17 20"
src="https://github.com/user-attachments/assets/9f751ea4-0ce0-4a23-a3b7-0a16494d957e"
/>

<img width="2558" alt="Screenshot 2025-01-02 at 13 18 24"
src="https://github.com/user-attachments/assets/6908ab02-4011-4a6e-85ce-e60d5eac7993"
/>

- Rule upgrade workflow (positive look-back)

<img width="2555" alt="Screenshot 2025-01-02 at 13 19 12"
src="https://github.com/user-attachments/assets/06208210-c6cd-4842-8aef-6ade5d13bd36"
/>

<img width="2558" alt="Screenshot 2025-01-02 at 13 25 31"
src="https://github.com/user-attachments/assets/aed38bb0-ccfb-479a-bb3b-e5442c518e63"
/>

- JSON view

<img width="2559" alt="Screenshot 2025-01-02 at 13 31 37"
src="https://github.com/user-attachments/assets/07575a81-676f-418e-8b98-48eefe11ab00"
/>

- Rule details page

<img width="2555" alt="Screenshot 2025-01-02 at 13 13 16"
src="https://github.com/user-attachments/assets/e977b752-9d50-4049-917a-af2e8e3f0dfe"
/>

<img width="2558" alt="Screenshot 2025-01-02 at 13 14 10"
src="https://github.com/user-attachments/assets/06d6f477-5730-48ca-a240-b5e7592bf173"
/>

## How to test?

- Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled
- Allow internal APIs via adding `server.restrictInternalApis: false` to
`kibana.dev.yaml`
- Clear Elasticsearch data
- Run Elasticsearch and Kibana locally (do not open Kibana in a web
browser)
- Install an outdated version of the `security_detection_engine` Fleet
package
```bash
curl -X POST --user elastic:changeme  -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.14.1
```

- Install prebuilt rules
```bash
curl -X POST --user elastic:changeme  -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 1" -d '{"mode":"ALL_RULES"}' http://localhost:5601/kbn/internal/detection_engine/prebuilt_rules/installation/_perform
```

- Set "inconsistent" rule schedule for `Suspicious File Creation via
Kworker` rule by running a query below
```bash
curl -X PATCH --user elastic:changeme -H "Content-Type: application/json" -H "elastic-api-version: 2023-10-31" -H "kbn-xsrf: 123" -d '{"rule_id":"ae343298-97bc-47bc-9ea2-5f2ad831c16e","interval":"10m","from":"now-5m","to":"now-2m"}' http://localhost:5601/kbn/api/detection_engine/rules
```

- Open rule upgrade flyout for `Suspicious File Creation via Kworker`
rule

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Maxim Palenov 2025-01-20 14:41:23 +01:00 committed by GitHub
parent a0bdc19684
commit 30bb71a516
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1843 additions and 619 deletions

2
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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 dhistorique",
"xpack.securitySolution.detectionEngine.ruleDetails.indexFieldLabel": "Modèles d'indexation",
"xpack.securitySolution.detectionEngine.ruleDetails.installAndEnableButtonLabel": "Installer et activer",

View file

@ -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": "インストールして有効化",

View file

@ -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": "安装并启用",

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './src/date_math';

View file

@ -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"
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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();
});
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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;
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './calc_date_math_diff';
export * from './normalize_date_math';

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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);
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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;
}
}

View file

@ -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);
});
});
});

View file

@ -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('|')})$`);

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './src/time_duration/time_duration';

View file

@ -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/**/*"]
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { z } from '@kbn/zod';
import { RuleIntervalFrom, RuleIntervalTo } from './common_attributes.gen';
import { TimeDuration as TimeDurationSchema } from './time_duration';
export type RuleSchedule = z.infer<typeof RuleSchedule>;
export const RuleSchedule = z.object({
interval: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
from: RuleIntervalFrom,
to: RuleIntervalTo,
});
/**
* Simpler version of RuleSchedule. It's only feasible when
* - `to` equals `now` (current moment in time)
* - `from` is less than `now` - `interval`
*
* Examples:
*
* - rule schedule: interval = 10m, from = now-15m, to = now
* simpler rule schedule: interval = 10m, lookback = 5m
*
* - rule schedule: interval = 1h, from = now-120m, to = now
* simpler rule schedule: interval = 10m, lookback = 5m
*/
export type SimpleRuleSchedule = z.infer<typeof SimpleRuleSchedule>;
export const SimpleRuleSchedule = z.object({
/**
* Rule running interval in time duration format, e.g. `2m`, `3h`
*/
interval: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
/**
* Non-negative additional source events look-back to compensate rule execution delays
* in time duration format, e.g. `2m`, `3h`.
*
* Having `interval`, `from` and `to` and can be calculated as
*
* lookback = now - `interval` - `from`, where `now` is the current moment in time
*
* In the other words rules use time range [now - interval - lookback, now]
* to select source events for analysis.
*/
lookback: TimeDurationSchema({ allowedUnits: ['s', 'm', 'h'] }),
});

View file

@ -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();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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(),
};
}

View file

@ -23,7 +23,6 @@ import {
TimestampOverride,
TimestampOverrideFallbackDisabled,
} from '../../../../model/rule_schema';
import { TimeDuration } from '../../../../model/rule_schema/time_duration';
// -------------------------------------------------------------------------------------------------
// Rule data source
@ -92,15 +91,6 @@ export const RuleEsqlQuery = z.object({
language: z.literal('esql'),
});
// -------------------------------------------------------------------------------------------------
// Rule schedule
export type RuleSchedule = z.infer<typeof RuleSchedule>;
export const RuleSchedule = z.object({
interval: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }),
lookback: TimeDuration({ allowedUnits: ['s', 'm', 'h'] }),
});
// -------------------------------------------------------------------------------------------------
// Rule name override

View file

@ -45,10 +45,10 @@ import {
RuleEsqlQuery,
RuleKqlQuery,
RuleNameOverrideObject,
RuleSchedule,
TimelineTemplateReference,
TimestampOverrideObject,
} from './diffable_field_types';
import { RuleSchedule } from '../../../../model/rule_schema/rule_schedule';
export type DiffableCommonFields = z.infer<typeof DiffableCommonFields>;
export const DiffableCommonFields = z.object({

View file

@ -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' });
});
});

View file

@ -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),
};
};

View file

@ -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 {

View file

@ -15,27 +15,17 @@ describe('ScheduleItemField', () => {
it('renders correctly', () => {
const mockField = useFormFieldMock<string>();
const wrapper = shallow(
<ScheduleItemField
dataTestSubj="schedule-item"
idAria="idAria"
isDisabled={false}
field={mockField}
/>
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
);
expect(wrapper.find('[data-test-subj="schedule-item"]')).toHaveLength(1);
});
it('accepts a large number via user input', () => {
it('accepts user input', () => {
const mockField = useFormFieldMock<string>();
const wrapper = mount(
<TestProviders>
<ScheduleItemField
dataTestSubj="schedule-item"
idAria="idAria"
isDisabled={false}
field={mockField}
/>
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
</TestProviders>
);
@ -47,17 +37,20 @@ describe('ScheduleItemField', () => {
expect(mockField.setValue).toHaveBeenCalledWith('5000000s');
});
it('clamps a number value greater than MAX_SAFE_INTEGER to MAX_SAFE_INTEGER', () => {
const unsafeInput = '99999999999999999999999';
it.each([
[-10, -5],
[-5, 0],
[5, 10],
[60, 90],
])('saturates a value "%s" lower than minValue', (unsafeInput, expected) => {
const mockField = useFormFieldMock<string>();
const wrapper = mount(
<TestProviders>
<ScheduleItemField
field={mockField}
minValue={expected}
dataTestSubj="schedule-item"
idAria="idAria"
isDisabled={false}
field={mockField}
/>
</TestProviders>
);
@ -67,22 +60,42 @@ describe('ScheduleItemField', () => {
.last()
.simulate('change', { target: { value: unsafeInput } });
const expectedValue = `${Number.MAX_SAFE_INTEGER}s`;
expect(mockField.setValue).toHaveBeenCalledWith(expectedValue);
expect(mockField.setValue).toHaveBeenCalledWith(`${expected}s`);
});
it('converts a non-numeric value to 0', () => {
it.each([
[-5, -10],
[5, 0],
[10, 5],
[90, 60],
])('saturates a value "%s" greater than maxValue', (unsafeInput, expected) => {
const mockField = useFormFieldMock<string>();
const wrapper = mount(
<TestProviders>
<ScheduleItemField
field={mockField}
maxValue={expected}
dataTestSubj="schedule-item"
idAria="idAria"
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="interval"]')
.last()
.simulate('change', { target: { value: unsafeInput } });
expect(mockField.setValue).toHaveBeenCalledWith(`${expected}s`);
});
it('skips updating a non-numeric values', () => {
const unsafeInput = 'this is not a number';
const mockField = useFormFieldMock<string>();
const wrapper = mount(
<TestProviders>
<ScheduleItemField
dataTestSubj="schedule-item"
idAria="idAria"
isDisabled={false}
field={mockField}
/>
<ScheduleItemField field={mockField} dataTestSubj="schedule-item" idAria="idAria" />
</TestProviders>
);
@ -91,6 +104,6 @@ describe('ScheduleItemField', () => {
.last()
.simulate('change', { target: { value: unsafeInput } });
expect(mockField.setValue).toHaveBeenCalledWith('0s');
expect(mockField.setValue).not.toHaveBeenCalled();
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { EuiSelectProps, EuiFieldNumberProps } from '@elastic/eui';
import {
EuiFlexGroup,
@ -14,8 +15,6 @@ import {
EuiSelect,
transparentize,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import type { FieldHook } from '../../../../shared_imports';
@ -27,8 +26,9 @@ interface ScheduleItemProps {
field: FieldHook<string>;
dataTestSubj: string;
idAria: string;
isDisabled: boolean;
minimumValue?: number;
isDisabled?: boolean;
minValue?: number;
maxValue?: number;
timeTypes?: string[];
fullWidth?: boolean;
}
@ -67,24 +67,16 @@ const MyEuiSelect = styled(EuiSelect)`
box-shadow: none;
`;
const getNumberFromUserInput = (input: string, minimumValue = 0): number => {
const number = parseInt(input, 10);
if (Number.isNaN(number)) {
return minimumValue;
} else {
return Math.max(minimumValue, Math.min(number, Number.MAX_SAFE_INTEGER));
}
};
export const ScheduleItemField = ({
dataTestSubj,
export function ScheduleItemField({
field,
idAria,
isDisabled,
minimumValue = 0,
timeTypes = ['s', 'm', 'h'],
dataTestSubj,
idAria,
minValue = Number.MIN_SAFE_INTEGER,
maxValue = Number.MAX_SAFE_INTEGER,
timeTypes = DEFAULT_TIME_DURATION_UNITS,
fullWidth = false,
}: ScheduleItemProps) => {
}: ScheduleItemProps): JSX.Element {
const [timeType, setTimeType] = useState(timeTypes[0]);
const [timeVal, setTimeVal] = useState<number>(0);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
@ -100,38 +92,40 @@ export const ScheduleItemField = ({
const onChangeTimeVal = useCallback<NonNullable<EuiFieldNumberProps['onChange']>>(
(e) => {
const sanitizedValue = getNumberFromUserInput(e.target.value, minimumValue);
setTimeVal(sanitizedValue);
setValue(`${sanitizedValue}${timeType}`);
const number = parseInt(e.target.value, 10);
if (Number.isNaN(number)) {
return;
}
const newTimeValue = saturate(number, minValue, maxValue);
setTimeVal(newTimeValue);
setValue(`${newTimeValue}${timeType}`);
},
[minimumValue, setValue, timeType]
[minValue, maxValue, setValue, timeType]
);
useEffect(() => {
if (value !== `${timeVal}${timeType}`) {
const filterTimeVal = value.match(/\d+/g);
const filterTimeType = value.match(/[a-zA-Z]+/g);
if (
!isEmpty(filterTimeVal) &&
filterTimeVal != null &&
!isNaN(Number(filterTimeVal[0])) &&
Number(filterTimeVal[0]) !== Number(timeVal)
) {
setTimeVal(Number(filterTimeVal[0]));
}
if (
!isEmpty(filterTimeType) &&
filterTimeType != null &&
timeTypes.includes(filterTimeType[0]) &&
filterTimeType[0] !== timeType
) {
setTimeType(filterTimeType[0]);
}
if (value === `${timeVal}${timeType}`) {
return;
}
const isNegative = value.startsWith('-');
const durationRegexp = new RegExp(`^\\-?(\\d+)(${timeTypes.join('|')})$`);
const durationMatchArray = value.match(durationRegexp);
if (!durationMatchArray) {
return;
}
const [, timeStr, unit] = durationMatchArray;
const time = parseInt(timeStr, 10) * (isNegative ? -1 : 1);
setTimeVal(time);
setTimeType(unit);
}, [timeType, timeTypes, timeVal, value]);
// EUI missing some props
const rest = { disabled: isDisabled };
const label = useMemo(
() => (
<EuiFlexGroup gutterSize="s" justifyContent="flexStart" alignItems="center">
@ -161,21 +155,27 @@ export const ScheduleItemField = ({
<MyEuiSelect
fullWidth
options={timeTypeOptions.filter((type) => timeTypes.includes(type.value))}
onChange={onChangeTimeType}
value={timeType}
onChange={onChangeTimeType}
disabled={isDisabled}
aria-label={field.label}
data-test-subj="timeType"
{...rest}
/>
}
fullWidth
min={minimumValue}
max={Number.MAX_SAFE_INTEGER}
onChange={onChangeTimeVal}
min={minValue}
max={maxValue}
value={timeVal}
onChange={onChangeTimeVal}
disabled={isDisabled}
data-test-subj="interval"
{...rest}
/>
</StyledEuiFormRow>
);
};
}
const DEFAULT_TIME_DURATION_UNITS = ['s', 'm', 'h'];
function saturate(input: number, minValue: number, maxValue: number): number {
return Math.max(minValue, Math.min(input, maxValue));
}

View file

@ -48,7 +48,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
idAria: 'detectionEngineStepScheduleRuleInterval',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepScheduleRuleInterval',
minimumValue: 1,
minValue: 1,
}}
/>
<UseField
@ -58,7 +58,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
idAria: 'detectionEngineStepScheduleRuleFrom',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepScheduleRuleFrom',
minimumValue: 1,
minValue: 0,
}}
/>
</StyledForm>

View file

@ -27,7 +27,6 @@ import type {
} from '../../../../detections/pages/detection_engine/rules/types';
import { AlertSuppressionDurationType } from '../../../../detections/pages/detection_engine/rules/types';
import {
getTimeTypeValue,
formatDefineStepData,
formatScheduleStepData,
formatAboutStepData,
@ -54,56 +53,6 @@ import {
} from '../../../rule_creation/components/alert_suppression_edit';
describe('helpers', () => {
describe('getTimeTypeValue', () => {
test('returns timeObj with value 0 if no time value found', () => {
const result = getTimeTypeValue('m');
expect(result).toEqual({ unit: 'm', value: 0 });
});
test('returns timeObj with unit set to default unit value of "ms" if no expected time type found', () => {
const result = getTimeTypeValue('5l');
expect(result).toEqual({ unit: 'ms', value: 5 });
});
test('returns timeObj with unit of s and value 5 when time is 5s ', () => {
const result = getTimeTypeValue('5s');
expect(result).toEqual({ unit: 's', value: 5 });
});
test('returns timeObj with unit of m and value 5 when time is 5m ', () => {
const result = getTimeTypeValue('5m');
expect(result).toEqual({ unit: 'm', value: 5 });
});
test('returns timeObj with unit of h and value 5 when time is 5h ', () => {
const result = getTimeTypeValue('5h');
expect(result).toEqual({ unit: 'h', value: 5 });
});
test('returns timeObj with value of 5 when time is float like 5.6m ', () => {
const result = getTimeTypeValue('5m');
expect(result).toEqual({ unit: 'm', value: 5 });
});
test('returns timeObj with value of 0 and unit of "ms" if random string passed in', () => {
const result = getTimeTypeValue('random');
expect(result).toEqual({ unit: 'ms', value: 0 });
});
test('returns timeObj with unit of d and value 5 when time is 5d ', () => {
const result = getTimeTypeValue('5d');
expect(result).toEqual({ unit: 'd', value: 5 });
});
});
describe('filterEmptyThreats', () => {
let mockThreat: Threat;
@ -639,12 +588,9 @@ describe('helpers', () => {
test('returns formatted object as ScheduleStepRuleJson', () => {
const result = formatScheduleStepData(mockData);
const expected: ScheduleStepRuleJson = {
from: 'now-660s',
from: 'now-11m',
to: 'now',
interval: '5m',
meta: {
from: '6m',
},
};
expect(result).toEqual(expected);
@ -657,12 +603,9 @@ describe('helpers', () => {
delete mockStepData.to;
const result = formatScheduleStepData(mockStepData);
const expected: ScheduleStepRuleJson = {
from: 'now-660s',
from: 'now-11m',
to: 'now',
interval: '5m',
meta: {
from: '6m',
},
};
expect(result).toEqual(expected);
@ -675,51 +618,34 @@ describe('helpers', () => {
};
const result = formatScheduleStepData(mockStepData);
const expected: ScheduleStepRuleJson = {
from: 'now-660s',
from: 'now-11m',
to: 'now',
interval: '5m',
meta: {
from: '6m',
},
};
expect(result).toEqual(expected);
});
test('returns formatted object if "from" random string', () => {
test('returns unchanged data when "from" is a random string', () => {
const mockStepData: ScheduleStepRule = {
...mockData,
from: 'random',
};
const result = formatScheduleStepData(mockStepData);
const expected: ScheduleStepRuleJson = {
from: 'now-300s',
to: 'now',
interval: '5m',
meta: {
from: 'random',
},
};
expect(result).toEqual(expected);
const result = formatScheduleStepData(mockStepData);
expect(result).toMatchObject(mockStepData);
});
test('returns formatted object if "interval" random string', () => {
test('returns unchanged data when "interval" is a random string', () => {
const mockStepData: ScheduleStepRule = {
...mockData,
interval: 'random',
};
const result = formatScheduleStepData(mockStepData);
const expected: ScheduleStepRuleJson = {
from: 'now-360s',
to: 'now',
interval: 'random',
meta: {
from: '6m',
},
};
expect(result).toEqual(expected);
const result = formatScheduleStepData(mockStepData);
expect(result).toMatchObject(mockStepData);
});
});

View file

@ -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 = (

View file

@ -28,18 +28,6 @@ function findChildByTextContent(parent: Element, textContent: string): HTMLEleme
) as HTMLElement;
}
/*
Finds a diff line element (".diff-line") that contains a particular text content.
Match doesn't have to be exact, it's enough for the line to include the text.
*/
function findDiffLineContaining(text: string): Element | null {
const foundLine = Array.from(document.querySelectorAll('.diff-line')).find((element) =>
(element.textContent || '').includes(text)
);
return foundLine || null;
}
describe('Rule upgrade workflow: viewing rule changes in JSON diff view', () => {
it.each(['light', 'dark'] as const)(
'User can see precisely how property values would change after upgrade - %s theme',
@ -210,68 +198,6 @@ describe('Rule upgrade workflow: viewing rule changes in JSON diff view', () =>
);
});
it('Properties with semantically equal values should not be shown as modified', () => {
const oldRule: RuleResponse = {
...savedRuleMock,
version: 1,
};
const newRule: RuleResponse = {
...savedRuleMock,
version: 2,
};
/* DURATION */
/* Semantically equal durations should not be shown as modified */
const { rerender } = render(
<RuleDiffTab
oldRule={{ ...oldRule, from: 'now-1h' }}
newRule={{ ...newRule, from: 'now-60m' }}
/>
);
expect(findDiffLineContaining('"from":')).toBeNull();
rerender(
<RuleDiffTab
oldRule={{ ...oldRule, from: 'now-1h' }}
newRule={{ ...newRule, from: 'now-3600s' }}
/>
);
expect(findDiffLineContaining('"from":')).toBeNull();
rerender(
<RuleDiffTab
oldRule={{ ...oldRule, from: 'now-7200s' }}
newRule={{ ...newRule, from: 'now-2h' }}
/>
);
expect(findDiffLineContaining('"from":')).toBeNull();
/* Semantically different durations should generate diff */
rerender(
<RuleDiffTab
oldRule={{ ...oldRule, from: 'now-7260s' }}
newRule={{ ...newRule, from: 'now-2h' }}
/>
);
expect(findDiffLineContaining('- "from": "now-7260s",+ "from": "now-7200s",')).not.toBeNull();
/* NOTE - Investigation guide */
rerender(<RuleDiffTab oldRule={{ ...oldRule, note: '' }} newRule={{ ...newRule }} />);
expect(findDiffLineContaining('"note":')).toBeNull();
rerender(
<RuleDiffTab oldRule={{ ...oldRule, note: '' }} newRule={{ ...newRule, note: undefined }} />
);
expect(findDiffLineContaining('"note":')).toBeNull();
rerender(<RuleDiffTab oldRule={{ ...oldRule }} newRule={{ ...newRule, note: '' }} />);
expect(findDiffLineContaining('"note":')).toBeNull();
rerender(<RuleDiffTab oldRule={{ ...oldRule }} newRule={{ ...newRule, note: 'abc' }} />);
expect(findDiffLineContaining('- "note": "",+ "note": "abc",')).not.toBeNull();
});
it('Unchanged sections of a rule should be hidden by default', async () => {
const oldRule: RuleResponse = {
...savedRuleMock,

View file

@ -0,0 +1,392 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import type { ThreeWayDiff } from '../../../../../../common/api/detection_engine';
import { getFieldDiffsForRuleSchedule } from './get_field_diffs_for_grouped_fields';
describe('getFieldDiffsForRuleSchedule', () => {
describe('full rule schedule', () => {
it('returns interval diff', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-8m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-8m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
]);
});
it('returns from diff', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-8m',
to: 'now',
},
target_version: {
interval: '10m',
from: 'now-7m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'from',
currentVersion: 'now-8m',
targetVersion: 'now-7m',
},
]);
});
it('returns to diff', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-5m',
to: 'now',
},
target_version: {
interval: '10m',
from: 'now-5m',
to: 'now-2m',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'to',
currentVersion: 'now',
targetVersion: 'now-2m',
},
]);
});
it('returns full diff', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-5m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-6m',
to: 'now-2m',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'from',
currentVersion: 'now-5m',
targetVersion: 'now-6m',
},
{
fieldName: 'to',
currentVersion: 'now',
targetVersion: 'now-2m',
},
]);
});
it('returns full diff when current lookback is negative', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-5m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-15m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'from',
currentVersion: 'now-5m',
targetVersion: 'now-15m',
},
]);
});
it('returns full diff when current to is not now', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-15m',
to: 'now-2m',
},
target_version: {
interval: '11m',
from: 'now-15m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'to',
currentVersion: 'now-2m',
targetVersion: 'now',
},
]);
});
it('returns full diff when target lookback is negative', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-15m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-5m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'from',
currentVersion: 'now-15m',
targetVersion: 'now-5m',
},
]);
});
it('returns full diff when target to is not now', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-15m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-15m',
to: 'now-2m',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'to',
currentVersion: 'now',
targetVersion: 'now-2m',
},
]);
});
it('returns diff with current undefined', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: undefined,
target_version: {
interval: '11m',
from: 'now-8m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule | undefined>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '',
targetVersion: '11m',
},
{
fieldName: 'from',
currentVersion: '',
targetVersion: 'now-8m',
},
{
fieldName: 'to',
currentVersion: '',
targetVersion: 'now',
},
]);
});
it('returns diff with target undefined', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '11m',
from: 'now-8m',
to: 'now',
},
target_version: undefined,
} as ThreeWayDiff<RuleSchedule | undefined>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '11m',
targetVersion: '',
},
{
fieldName: 'from',
currentVersion: 'now-8m',
targetVersion: '',
},
{
fieldName: 'to',
currentVersion: 'now',
targetVersion: '',
},
]);
});
});
describe('simple rule schedule', () => {
it('returns diff', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-11m',
to: 'now',
},
target_version: {
interval: '11m',
from: 'now-11m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '11m',
},
{
fieldName: 'lookback',
currentVersion: '1m',
targetVersion: '0s',
},
]);
});
it('returns diff when current is undefined', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: undefined,
target_version: {
interval: '11m',
from: 'now-11m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule | undefined>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '',
targetVersion: '11m',
},
{
fieldName: 'lookback',
currentVersion: '',
targetVersion: '0s',
},
]);
});
it('returns diff when target is undefined', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-11m',
to: 'now',
},
target_version: undefined,
} as ThreeWayDiff<RuleSchedule | undefined>);
expect(result).toEqual([
{
fieldName: 'interval',
currentVersion: '10m',
targetVersion: '',
},
{
fieldName: 'lookback',
currentVersion: '1m',
targetVersion: '',
},
]);
});
});
describe('no diff', () => {
it('returns empty array for equal versions', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: {
interval: '10m',
from: 'now-15m',
to: 'now',
},
target_version: {
interval: '10m',
from: 'now-15m',
to: 'now',
},
} as ThreeWayDiff<RuleSchedule>);
expect(result).toEqual([]);
});
it('returns empty array for undefined versions', () => {
const result = getFieldDiffsForRuleSchedule({
current_version: undefined,
target_version: undefined,
} as ThreeWayDiff<RuleSchedule | undefined>);
expect(result).toEqual([]);
});
});
});

View file

@ -6,12 +6,18 @@
*/
import stringify from 'json-stable-stringify';
import type {
RuleSchedule,
SimpleRuleSchedule,
} from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import { toSimpleRuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import type {
AllFieldsDiff,
RuleFieldsDiffWithDataSource,
RuleFieldsDiffWithEqlQuery,
RuleFieldsDiffWithEsqlQuery,
RuleFieldsDiffWithKqlQuery,
ThreeWayDiff,
} from '../../../../../../common/api/detection_engine';
import type { FieldDiff } from '../../../model/rule_details/rule_field_diff';
@ -277,34 +283,75 @@ export const getFieldDiffsForThreatQuery = (
};
export const getFieldDiffsForRuleSchedule = (
ruleScheduleThreeWayDiff: AllFieldsDiff['rule_schedule']
ruleScheduleThreeWayDiff: ThreeWayDiff<RuleSchedule | undefined>
): FieldDiff[] => {
return [
...(ruleScheduleThreeWayDiff.current_version?.interval !==
ruleScheduleThreeWayDiff.target_version?.interval
? [
{
fieldName: 'interval',
currentVersion: sortAndStringifyJson(
ruleScheduleThreeWayDiff.current_version?.interval
),
targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.interval),
},
]
: []),
...(ruleScheduleThreeWayDiff.current_version?.lookback !==
ruleScheduleThreeWayDiff.target_version?.lookback
? [
{
fieldName: 'lookback',
currentVersion: sortAndStringifyJson(
ruleScheduleThreeWayDiff.current_version?.lookback
),
targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.lookback),
},
]
: []),
];
const fieldsDiff: FieldDiff[] = [];
const current = ruleScheduleThreeWayDiff.current_version;
const target = ruleScheduleThreeWayDiff.target_version;
const currentSimpleRuleSchedule = current ? toSimpleRuleSchedule(current) : undefined;
const targetSimpleRuleSchedule = target ? toSimpleRuleSchedule(target) : undefined;
const isCurrentSimpleRuleScheduleValid = !current || (current && currentSimpleRuleSchedule);
const isTargetSimpleRuleScheduleValid = !target || (target && targetSimpleRuleSchedule);
// Show simple rule schedule diff only when current and target versions are convertable
// to simple rule schedule or one of the versions is undefined.
if (isCurrentSimpleRuleScheduleValid && isTargetSimpleRuleScheduleValid) {
return getFieldDiffsForSimpleRuleSchedule(currentSimpleRuleSchedule, targetSimpleRuleSchedule);
}
if (current?.interval !== target?.interval) {
fieldsDiff.push({
fieldName: 'interval',
currentVersion: sortAndStringifyJson(current?.interval),
targetVersion: sortAndStringifyJson(target?.interval),
});
}
if (current?.from !== target?.from) {
fieldsDiff.push({
fieldName: 'from',
currentVersion: sortAndStringifyJson(current?.from),
targetVersion: sortAndStringifyJson(target?.from),
});
}
if (current?.to !== target?.to) {
fieldsDiff.push({
fieldName: 'to',
currentVersion: sortAndStringifyJson(current?.to),
targetVersion: sortAndStringifyJson(target?.to),
});
}
return fieldsDiff;
};
const getFieldDiffsForSimpleRuleSchedule = (
current: SimpleRuleSchedule | undefined,
target: SimpleRuleSchedule | undefined
): FieldDiff[] => {
const fieldsDiff: FieldDiff[] = [];
if (current?.interval !== target?.interval) {
fieldsDiff.push({
fieldName: 'interval',
currentVersion: sortAndStringifyJson(current?.interval),
targetVersion: sortAndStringifyJson(target?.interval),
});
}
if (current?.lookback !== target?.lookback) {
fieldsDiff.push({
fieldName: 'lookback',
currentVersion: sortAndStringifyJson(current?.lookback),
targetVersion: sortAndStringifyJson(target?.lookback),
});
}
return fieldsDiff;
};
export const getFieldDiffsForRuleNameOverride = (

View file

@ -18,14 +18,10 @@ import {
} from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { normalizeMachineLearningJobIds } from '../../../../../common/detection_engine/utils';
import {
formatScheduleStepData,
filterEmptyThreats,
} from '../../../rule_creation_ui/pages/rule_creation/helpers';
import { filterEmptyThreats } from '../../../rule_creation_ui/pages/rule_creation/helpers';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen';
import { DiffView } from './json_diff/diff_view';
import * as i18n from './json_diff/translations';
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
/* Inclding these properties in diff display might be confusing to users. */
const HIDDEN_PROPERTIES: Array<keyof RuleResponse> = [
@ -79,20 +75,6 @@ const sortAndStringifyJson = (jsObject: Record<string, unknown>): string =>
const normalizeRule = (originalRule: RuleResponse): RuleResponse => {
const rule = { ...originalRule };
/*
Convert the "from" property value to a humanized duration string, like 'now-1m' or 'now-2h'.
Conversion is needed to skip showing the diff for the "from" property when the same
duration is represented in different time units. For instance, 'now-1h' and 'now-3600s'
indicate a one-hour duration.
The same helper is used in the rule editing UI to format "from" before submitting the edits.
So, after the rule is saved, the "from" property unit/value might differ from what's in the package.
*/
rule.from = formatScheduleStepData({
interval: rule.interval,
from: getHumanizedDuration(rule.from, rule.interval),
to: rule.to,
}).from;
/*
Default "note" to an empty string if it's not present.
Sometimes, in a new version of a rule, the "note" value equals an empty string, while

View file

@ -8,9 +8,10 @@
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import { normalizeDateMath } from '@kbn/securitysolution-utils/date_math';
import { toSimpleRuleSchedule } from '../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import { IntervalAbbrScreenReader } from '../../../../common/components/accessibility';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import * as i18n from './translations';
@ -36,16 +37,12 @@ const Interval = ({ interval }: IntervalProps) => (
<AccessibleTimeValue timeValue={interval} data-test-subj="intervalPropertyValue" />
);
interface FromProps {
from: string;
interval: string;
interface LookBackProps {
value: string;
}
const From = ({ from, interval }: FromProps) => (
<AccessibleTimeValue
timeValue={getHumanizedDuration(from, interval)}
data-test-subj={`fromPropertyValue-${from}`}
/>
const LookBack = ({ value }: LookBackProps) => (
<AccessibleTimeValue timeValue={value} data-test-subj={`lookBackPropertyValue-${value}`} />
);
export interface RuleScheduleSectionProps extends React.ComponentProps<typeof EuiDescriptionList> {
@ -62,18 +59,46 @@ export const RuleScheduleSection = ({
return null;
}
const ruleSectionListItems = [];
const to = rule.to ?? 'now';
ruleSectionListItems.push(
{
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
description: <Interval interval={rule.interval} />,
},
{
title: <span data-test-subj="fromPropertyTitle">{i18n.FROM_FIELD_LABEL}</span>,
description: <From from={rule.from} interval={rule.interval} />,
}
);
const simpleRuleSchedule = toSimpleRuleSchedule({
interval: rule.interval,
from: rule.from,
to,
});
const ruleSectionListItems = !simpleRuleSchedule
? [
{
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
description: <Interval interval={rule.interval} />,
},
{
title: (
<span data-test-subj="fromToPropertyTitle">
{i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL}
</span>
),
description: (
<span data-test-subj="fromToPropertyValue">
{i18n.RULE_SOURCE_EVENTS_TIME_RANGE(
normalizeDateMath(rule.from),
normalizeDateMath(to)
)}
</span>
),
},
]
: [
{
title: <span data-test-subj="intervalPropertyTitle">{i18n.INTERVAL_FIELD_LABEL}</span>,
description: <Interval interval={simpleRuleSchedule.interval} />,
},
{
title: <span data-test-subj="lookBackPropertyTitle">{i18n.LOOK_BACK_FIELD_LABEL}</span>,
description: <LookBack value={simpleRuleSchedule.lookback} />,
},
];
return (
<div data-test-subj="listItemColumnStepRuleDescription">

View file

@ -5,14 +5,23 @@
* 2.0.
*/
import type { SimpleRuleSchedule } from '../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import { toSimpleRuleSchedule } from '../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import { stringifyToSortedJson } from '../utils';
import type { DiffableAllFields } from '../../../../../../../../common/api/detection_engine';
import type { SubfieldChange } from '../types';
export const getSubfieldChangesForRuleSchedule = (
export function getSubfieldChangesForRuleSchedule(
oldFieldValue?: DiffableAllFields['rule_schedule'],
newFieldValue?: DiffableAllFields['rule_schedule']
): SubfieldChange[] => {
): SubfieldChange[] {
const oldSimpleRuleSchedule = oldFieldValue ? toSimpleRuleSchedule(oldFieldValue) : undefined;
const newSimpleRuleSchedule = newFieldValue ? toSimpleRuleSchedule(newFieldValue) : undefined;
if (oldSimpleRuleSchedule && newSimpleRuleSchedule) {
return getSubfieldChangesForSimpleRuleSchedule(oldSimpleRuleSchedule, newSimpleRuleSchedule);
}
const changes: SubfieldChange[] = [];
if (oldFieldValue?.interval !== newFieldValue?.interval) {
@ -23,13 +32,46 @@ export const getSubfieldChangesForRuleSchedule = (
});
}
if (oldFieldValue?.lookback !== newFieldValue?.lookback) {
if (oldFieldValue?.from !== newFieldValue?.from) {
changes.push({
subfieldName: 'lookback',
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.lookback),
newSubfieldValue: stringifyToSortedJson(newFieldValue?.lookback),
subfieldName: 'from',
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.from),
newSubfieldValue: stringifyToSortedJson(newFieldValue?.from),
});
}
if (oldFieldValue?.to !== newFieldValue?.to) {
changes.push({
subfieldName: 'to',
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.to),
newSubfieldValue: stringifyToSortedJson(newFieldValue?.to),
});
}
return changes;
};
}
function getSubfieldChangesForSimpleRuleSchedule(
oldFieldValue: SimpleRuleSchedule,
newFieldValue: SimpleRuleSchedule
): SubfieldChange[] {
const changes: SubfieldChange[] = [];
if (oldFieldValue.interval !== newFieldValue.interval) {
changes.push({
subfieldName: 'interval',
oldSubfieldValue: stringifyToSortedJson(oldFieldValue?.interval),
newSubfieldValue: stringifyToSortedJson(newFieldValue?.interval),
});
}
if (oldFieldValue.lookback !== newFieldValue.lookback) {
changes.push({
subfieldName: 'lookback',
oldSubfieldValue: stringifyToSortedJson(oldFieldValue.lookback),
newSubfieldValue: stringifyToSortedJson(newFieldValue.lookback),
});
}
return changes;
}

View file

@ -65,12 +65,6 @@ import {
ruleNameOverrideSerializer,
ruleNameOverrideSchema,
} from './fields/rule_name_override';
import {
RuleScheduleEdit,
ruleScheduleSchema,
ruleScheduleDeserializer,
ruleScheduleSerializer,
} from './fields/rule_schedule';
import { SetupEdit, setupSchema } from './fields/setup';
import { SeverityEdit } from './fields/severity';
import {
@ -92,6 +86,7 @@ import {
timestampOverrideSerializer,
timestampOverrideSchema,
} from './fields/timestamp_override';
import { RuleScheduleForm } from './fields/rule_schedule';
interface CommonRuleFieldEditProps {
fieldName: UpgradeableCommonFields;
@ -200,14 +195,7 @@ export function CommonRuleFieldEdit({ fieldName }: CommonRuleFieldEditProps) {
/>
);
case 'rule_schedule':
return (
<RuleFieldEditFormWrapper
component={RuleScheduleEdit}
ruleFieldFormSchema={ruleScheduleSchema}
serializer={ruleScheduleSerializer}
deserializer={ruleScheduleDeserializer}
/>
);
return <RuleScheduleForm />;
case 'setup':
return <RuleFieldEditFormWrapper component={SetupEdit} ruleFieldFormSchema={setupSchema} />;
case 'severity':

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { parseDuration } from '@kbn/alerting-plugin/common';
import { type FormSchema, type FormData, UseField } from '../../../../../../../shared_imports';
import { schema } from '../../../../../../rule_creation_ui/components/step_schedule_rule/schema';
import type { RuleSchedule } from '../../../../../../../../common/api/detection_engine';
import { secondsToDurationString } from '../../../../../../../detections/pages/detection_engine/rules/helpers';
import { ScheduleItemField } from '../../../../../../rule_creation/components/schedule_item_field';
export const ruleScheduleSchema = {
interval: schema.interval,
from: schema.from,
} as FormSchema<{
interval: string;
from: string;
}>;
const componentProps = {
minimumValue: 1,
};
export function RuleScheduleEdit(): JSX.Element {
return (
<>
<UseField path="interval" component={ScheduleItemField} componentProps={componentProps} />
<UseField path="from" component={ScheduleItemField} componentProps={componentProps} />
</>
);
}
export function ruleScheduleDeserializer(defaultValue: FormData) {
const lookbackSeconds = parseDuration(defaultValue.rule_schedule.lookback) / 1000;
const lookbackHumanized = secondsToDurationString(lookbackSeconds);
return {
interval: defaultValue.rule_schedule.interval,
from: lookbackHumanized,
};
}
export function ruleScheduleSerializer(formData: FormData): {
rule_schedule: RuleSchedule;
} {
return {
rule_schedule: {
interval: formData.interval,
lookback: formData.from,
},
};
}

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import type { FieldConfig } from '../../../../../../../../shared_imports';
import { UseField } from '../../../../../../../../shared_imports';
import { ScheduleItemField } from '../../../../../../../rule_creation/components/schedule_item_field';
import * as i18n from './translations';
import { dateMathValidator } from './validators/date_math_validator';
export function FullRuleScheduleAdapter(): JSX.Element {
return (
<>
<UseField
path="interval"
config={INTERVAL_FIELD_CONFIG}
component={ScheduleItemField}
componentProps={INTERVAL_COMPONENT_PROPS}
/>
<UseField path="from" config={FROM_FIELD_CONFIG} component={TextField} />
<UseField path="to" config={TO_FIELD_CONFIG} component={TextField} />
</>
);
}
const INTERVAL_COMPONENT_PROPS = {
minValue: 1,
};
const INTERVAL_FIELD_CONFIG: FieldConfig<string> = {
label: i18n.INTERVAL_FIELD_LABEL,
helpText: i18n.INTERVAL_FIELD_HELP_TEXT,
};
const FROM_FIELD_CONFIG: FieldConfig<string> = {
label: i18n.FROM_FIELD_LABEL,
helpText: i18n.DATE_MATH_HELP_TEXT,
validations: [
{
validator: dateMathValidator,
},
],
};
const TO_FIELD_CONFIG: FieldConfig<string> = {
label: i18n.TO_FIELD_LABEL,
helpText: i18n.DATE_MATH_HELP_TEXT,
validations: [
{
validator: dateMathValidator,
},
],
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
import { type FormData } from '../../../../../../../../shared_imports';
import { RuleFieldEditFormWrapper } from '../../../field_final_side';
import { FullRuleScheduleAdapter } from './full_rule_schedule_adapter';
export function FullRuleScheduleForm(): JSX.Element {
return (
<RuleFieldEditFormWrapper
component={FullRuleScheduleAdapter}
serializer={serializer}
deserializer={deserializer}
/>
);
}
function deserializer(_: unknown, finalRule: DiffableRule): RuleSchedule {
return {
interval: finalRule.rule_schedule.interval,
from: finalRule.rule_schedule.from,
to: finalRule.rule_schedule.to,
};
}
function serializer(formData: FormData): {
rule_schedule: RuleSchedule;
} {
return {
rule_schedule: {
interval: formData.interval,
from: formData.from,
to: formData.to,
},
};
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './rule_schedule_form';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { type RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import { SimpleRuleScheduleForm } from './simple_rule_schedule_form';
import { useFieldUpgradeContext } from '../../../rule_upgrade/field_upgrade_context';
import { FullRuleScheduleForm } from './full_rule_schedule_form';
export function RuleScheduleForm(): JSX.Element {
const { fieldName, finalDiffableRule } = useFieldUpgradeContext();
const canBeSimplified = useMemo(
() => Boolean(toSimpleRuleSchedule(finalDiffableRule[fieldName] as RuleSchedule)),
[fieldName, finalDiffableRule]
);
return canBeSimplified ? <SimpleRuleScheduleForm /> : <FullRuleScheduleForm />;
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { FieldConfig } from '../../../../../../../../shared_imports';
import { UseField } from '../../../../../../../../shared_imports';
import { ScheduleItemField } from '../../../../../../../rule_creation/components/schedule_item_field';
import * as i18n from './translations';
export function SimpleRuleScheduleAdapter(): JSX.Element {
return (
<>
<UseField
path="interval"
config={INTERVAL_FIELD_CONFIG}
component={ScheduleItemField}
componentProps={INTERVAL_COMPONENT_PROPS}
/>
<UseField
path="lookback"
config={LOOK_BACK_FIELD_CONFIG}
component={ScheduleItemField}
componentProps={LOOKBACK_COMPONENT_PROPS}
/>
</>
);
}
const INTERVAL_COMPONENT_PROPS = {
minValue: 1,
};
const LOOKBACK_COMPONENT_PROPS = {
minValue: 0,
};
const INTERVAL_FIELD_CONFIG: FieldConfig<string> = {
label: i18n.INTERVAL_FIELD_LABEL,
helpText: i18n.INTERVAL_FIELD_HELP_TEXT,
};
const LOOK_BACK_FIELD_CONFIG: FieldConfig<string> = {
label: i18n.LOOK_BACK_FIELD_LABEL,
helpText: i18n.LOOK_BACK_FIELD_HELP_TEXT,
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
import type {
RuleSchedule,
SimpleRuleSchedule,
} from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import { type FormData } from '../../../../../../../../shared_imports';
import type { DiffableRule } from '../../../../../../../../../common/api/detection_engine';
import { RuleFieldEditFormWrapper } from '../../../field_final_side';
import { SimpleRuleScheduleAdapter } from './simple_rule_schedule_adapter';
import { invariant } from '../../../../../../../../../common/utils/invariant';
export function SimpleRuleScheduleForm(): JSX.Element {
return (
<RuleFieldEditFormWrapper
component={SimpleRuleScheduleAdapter}
serializer={serializer}
deserializer={deserializer}
/>
);
}
function deserializer(_: unknown, finalRule: DiffableRule): Partial<SimpleRuleSchedule> {
const simpleRuleSchedule = toSimpleRuleSchedule(finalRule.rule_schedule);
invariant(simpleRuleSchedule, 'Unable to calculate simple rule schedule');
return {
interval: simpleRuleSchedule.interval,
lookback: simpleRuleSchedule.lookback,
};
}
function serializer(formData: FormData): {
rule_schedule: RuleSchedule;
} {
const interval = TimeDuration.parse(formData.interval);
const lookBack = TimeDuration.parse(formData.lookback);
invariant(interval !== undefined && interval.value > 0, 'Rule interval is invalid');
invariant(lookBack !== undefined && lookBack.value >= 0, "Rule's look-back is invalid");
const fromOffsetMs = interval.toMilliseconds() + lookBack.toMilliseconds();
const fromOffset = TimeDuration.fromMilliseconds(fromOffsetMs);
const from = `now-${fromOffset}`;
return {
rule_schedule: {
interval: formData.interval,
from,
to: 'now',
},
};
}

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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".',
}
);

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import dateMath from '@kbn/datemath';
import { type FormData, type ValidationFunc } from '../../../../../../../../../shared_imports';
import * as i18n from './translations';
export const dateMathValidator: ValidationFunc<FormData, string, string> = (data) => {
const { path, value } = data;
if (!dateMath.parse(value)) {
return { code: 'ERR_DATE_MATH_INVALID', path, message: i18n.INVALID_DATE_MATH };
}
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const INVALID_DATE_MATH = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleManagement.validation.dateMath.invalid',
{
defaultMessage: 'Date math is invalid. Valid examples: "now", "now-3h", "now+2m".',
}
);

View file

@ -17,7 +17,8 @@ export const Default = () => (
<RuleScheduleReadOnly
ruleSchedule={{
interval: '5m',
lookback: '60s',
from: 'now-360s',
to: 'now',
}}
/>
);

View file

@ -5,21 +5,36 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { parseDuration } from '@kbn/alerting-plugin/common';
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import { toSimpleRuleSchedule } from '../../../../../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import * as i18n from '../../../../translations';
import type { RuleSchedule } from '../../../../../../../../../common/api/detection_engine';
import { AccessibleTimeValue } from '../../../../rule_schedule_section';
import { secondsToDurationString } from '../../../../../../../../detections/pages/detection_engine/rules/helpers';
interface RuleScheduleReadOnlyProps {
ruleSchedule: RuleSchedule;
}
export function RuleScheduleReadOnly({ ruleSchedule }: RuleScheduleReadOnlyProps) {
const lookbackSeconds = parseDuration(ruleSchedule.lookback) / 1000;
const lookbackHumanized = secondsToDurationString(lookbackSeconds);
const simpleRuleSchedule = useMemo(() => toSimpleRuleSchedule(ruleSchedule), [ruleSchedule]);
if (simpleRuleSchedule) {
return (
<EuiDescriptionList
listItems={[
{
title: i18n.INTERVAL_FIELD_LABEL,
description: <AccessibleTimeValue timeValue={simpleRuleSchedule.interval} />,
},
{
title: i18n.LOOK_BACK_FIELD_LABEL,
description: <AccessibleTimeValue timeValue={simpleRuleSchedule.lookback} />,
},
]}
/>
);
}
return (
<EuiDescriptionList
@ -29,8 +44,10 @@ export function RuleScheduleReadOnly({ ruleSchedule }: RuleScheduleReadOnlyProps
description: <AccessibleTimeValue timeValue={ruleSchedule.interval} />,
},
{
title: i18n.FROM_FIELD_LABEL,
description: <AccessibleTimeValue timeValue={lookbackHumanized} />,
title: i18n.RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL,
description: (
<span>{i18n.RULE_SOURCE_EVENTS_TIME_RANGE(ruleSchedule.from, ruleSchedule.to)}</span>
),
},
]}
/>

View file

@ -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,
};

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
export const RULE_DETAILS_FLYOUT_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.label',
@ -386,13 +388,31 @@ export const INTERVAL_FIELD_LABEL = i18n.translate(
}
);
export const FROM_FIELD_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.fromFieldLabel',
export const LOOK_BACK_FIELD_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.lookBackFieldLabel',
{
defaultMessage: 'Additional look-back time',
}
);
export const RULE_SOURCE_EVENTS_TIME_RANGE_FIELD_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.ruleSourceEventsTimeRangeFieldLabel',
{
defaultMessage: 'Analyzed time range',
}
);
export const RULE_SOURCE_EVENTS_TIME_RANGE = (from: string, to: string) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.ruleDetails.ruleSourceEventsTimeRange"
defaultMessage="From {from} to {to}"
values={{
from: <strong>{from}</strong>,
to: <strong>{to}</strong>,
}}
/>
);
export const MAX_SIGNALS_FIELD_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.maxAlertsFieldLabel',
{

View file

@ -84,7 +84,7 @@ export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormCom
idAria: 'bulkEditRulesScheduleIntervalSelector',
dataTestSubj: 'bulkEditRulesScheduleIntervalSelector',
fullWidth: true,
minimumValue: 1,
minValue: 1,
}}
/>
<UseField
@ -94,7 +94,7 @@ export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormCom
idAria: 'bulkEditRulesScheduleLookbackSelector',
dataTestSubj: 'bulkEditRulesScheduleLookbackSelector',
fullWidth: true,
minimumValue: 1,
minValue: 1,
}}
/>
</BulkEditFormWrapper>

View file

@ -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 = {

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import dateMath from '@kbn/datemath';
import moment from 'moment';
import memoizeOne from 'memoize-one';
import { useLocation } from 'react-router-dom';
@ -22,6 +20,7 @@ import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
import type { Filter } from '@kbn/es-query';
import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public';
import { requiredOptional } from '@kbn/zod-helpers';
import { toSimpleRuleSchedule } from '../../../../../common/api/detection_engine/model/rule_schema/to_simple_rule_schedule';
import {
ALERT_SUPPRESSION_FIELDS_FIELD_NAME,
ALERT_SUPPRESSION_DURATION_TYPE_FIELD_NAME,
@ -191,45 +190,23 @@ export const getDefineStepsData = (rule: RuleResponse): DefineStepRule => ({
});
export const getScheduleStepsData = (rule: RuleResponse): ScheduleStepRule => {
const { interval, from } = rule;
const fromHumanizedValue = getHumanizedDuration(from, interval);
const simpleRuleSchedule = toSimpleRuleSchedule(rule);
if (simpleRuleSchedule) {
return {
interval: simpleRuleSchedule.interval,
from: simpleRuleSchedule.lookback,
};
}
return {
interval,
from: fromHumanizedValue,
interval: rule.interval,
// Fallback to zero look-back since UI isn't able to handle negative
// look-back
from: '0s',
};
};
/**
* Converts seconds to duration string, like "1h", "30m" or "15s"
*/
export const secondsToDurationString = (seconds: number): string => {
if (seconds === 0) {
return `0s`;
}
if (seconds % 3600 === 0) {
return `${seconds / 3600}h`;
} else if (seconds % 60 === 0) {
return `${seconds / 60}m`;
} else {
return `${seconds}s`;
}
};
export const getHumanizedDuration = (from: string, interval: string): string => {
const fromValue = dateMath.parse(from) ?? moment();
const intervalValue = dateMath.parse(`now-${interval}`) ?? moment();
const fromDuration = moment.duration(intervalValue.diff(fromValue));
// Basing calculations off floored seconds count as moment durations weren't precise
const intervalDuration = Math.floor(fromDuration.asSeconds());
// For consistency of display value
return secondsToDurationString(intervalDuration);
};
export const getAboutStepsData = (rule: RuleResponse, detailsView: boolean): AboutStepRule => {
const { name, description, note, setup } = determineDetailsValue(rule, detailsView);
const {

View file

@ -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' });
});
});

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { get, has } from 'lodash';
import type { RuleSchedule } from '../../../../../../common/api/detection_engine/model/rule_schema/rule_schedule';
import type {
RuleSchedule,
DataSourceIndexPatterns,
DataSourceDataView,
InlineKqlQuery,
@ -15,7 +15,6 @@ import type {
} from '../../../../../../common/api/detection_engine';
import { type AllFieldsDiff } from '../../../../../../common/api/detection_engine';
import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset';
import { calculateFromValue } from '../../../rule_types/utils/utils';
/**
* Retrieves and transforms the value for a specific field from a DiffableRule group.
@ -32,11 +31,6 @@ import { calculateFromValue } from '../../../rule_types/utils/utils';
* mapDiffableRuleFieldValueToRuleSchema('index', { index_patterns: ['logs-*'] })
* // Returns: ['logs-*']
*
* @example
* // For a 'from' field in a rule schedule
* mapDiffableRuleFieldValueToRuleSchema('from', { interval: '5d', lookback: '30d' })
* // Returns: 'now-30d'
*
*/
export const mapDiffableRuleFieldValueToRuleSchemaFormat = (
fieldName: keyof PrebuiltRuleAsset,
@ -142,8 +136,8 @@ const SUBFIELD_MAPPING: Record<string, string> = {
timeline_id: 'timeline_id',
timeline_title: 'timeline_title',
interval: 'interval',
from: 'lookback',
to: 'lookback',
from: 'from',
to: 'to',
};
/**
@ -157,10 +151,6 @@ const SUBFIELD_MAPPING: Record<string, string> = {
* mapRuleFieldToDiffableRuleSubfield('index')
* // Returns: 'index_patterns'
*
* @example
* mapRuleFieldToDiffableRuleSubfield('from')
* // Returns: 'lookback'
*
*/
export function mapRuleFieldToDiffableRuleSubfield(fieldName: string): string {
return SUBFIELD_MAPPING[fieldName] || fieldName;
@ -190,11 +180,6 @@ type TransformValuesReturnType =
* - If not transformed: { type: 'NON_TRANSFORMED_FIELD' }
*
* @example
* // Transforms 'from' field
* transformDiffableFieldValues('from', { lookback: '30d' })
* // Returns: { type: 'TRANSFORMED_FIELD', value: 'now-30d' }
*
* @example
* // Transforms 'saved_id' field for inline queries
* transformDiffableFieldValues('saved_id', { type: 'inline_query', ... })
* // Returns: { type: 'TRANSFORMED_FIELD', value: undefined }
@ -204,12 +189,7 @@ export const transformDiffableFieldValues = (
fieldName: string,
diffableFieldValue: RuleSchedule | InlineKqlQuery | unknown
): TransformValuesReturnType => {
if (fieldName === 'from' && isRuleSchedule(diffableFieldValue)) {
const from = calculateFromValue(diffableFieldValue.interval, diffableFieldValue.lookback);
return { type: 'TRANSFORMED_FIELD', value: from };
} else if (fieldName === 'to') {
return { type: 'TRANSFORMED_FIELD', value: `now` };
} else if (fieldName === 'saved_id' && isInlineQuery(diffableFieldValue)) {
if (fieldName === 'saved_id' && isInlineQuery(diffableFieldValue)) {
// saved_id should be set only for rules with SavedKqlQuery, undefined otherwise
return { type: 'TRANSFORMED_FIELD', value: undefined };
} else if (fieldName === 'data_view_id' && isDataSourceIndexPatterns(diffableFieldValue)) {
@ -221,10 +201,6 @@ export const transformDiffableFieldValues = (
return { type: 'NON_TRANSFORMED_FIELD' };
};
function isRuleSchedule(value: unknown): value is RuleSchedule {
return typeof value === 'object' && value !== null && 'lookback' in value;
}
function isInlineQuery(value: unknown): value is InlineKqlQuery {
return (
typeof value === 'object' && value !== null && 'type' in value && value.type === 'inline_query'

View file

@ -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"]';

View file

@ -14,6 +14,8 @@ import {
} from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema';
import type { Filter } from '@kbn/es-query';
import type { PrebuiltRuleAsset } from '@kbn/security-solution-plugin/server/lib/detection_engine/prebuilt_rules';
import { calcDateMathDiff } from '@kbn/securitysolution-utils/date_math';
import { TimeDuration } from '@kbn/securitysolution-utils/time_duration';
import {
ALERT_SUPPRESSION_DURATION_TITLE,
ALERT_SUPPRESSION_DURATION_VALUE,
@ -42,8 +44,8 @@ import {
FILTERS_TITLE,
FILTERS_VALUE_ITEM,
FLYOUT_CLOSE_BTN,
FROM_TITLE,
FROM_VALUE,
LOOK_BACK_TITLE,
LOOK_BACK_VALUE,
INDEX_TITLE,
INDEX_VALUE_ITEM,
INSTALL_PREBUILT_RULE_PREVIEW,
@ -227,8 +229,15 @@ export const assertCommonPropertiesShown = (properties: Partial<PrebuiltRuleAsse
cy.get(INTERVAL_TITLE).should('have.text', 'Runs every');
cy.get(INTERVAL_VALUE).should('contain.text', properties.interval);
cy.get(FROM_TITLE).should('have.text', 'Additional look-back time');
cy.get(FROM_VALUE).invoke('attr', 'data-test-subj').should('contain', properties.from);
cy.get(LOOK_BACK_TITLE).should('have.text', 'Additional look-back time');
cy.get(LOOK_BACK_VALUE)
.invoke('attr', 'data-test-subj')
.should(
'contain',
TimeDuration.fromMilliseconds(
calcDateMathDiff(properties.from ?? 'now-6m', `now-${properties.interval}`) ?? 0
).toString()
);
};
export const assertIndexPropertyShown = (index: string[]) => {

View file

@ -46,5 +46,6 @@
"@kbn/elastic-assistant-common",
"@kbn/cloud-security-posture-common",
"@kbn/security-plugin-types-common",
"@kbn/securitysolution-utils"
]
}