Skip to content

Commit e77ac65

Browse files
committed
[Security Solution] Allow partial matches on rule name when searching installed rules. (elastic#237496)
**Fixes: elastic#237278** **Fixes: elastic#97094** **Fixes: elastic#194066** ## Summary When a user navigates to `Rules` > `Detection Rules (SIEM)` and wishes to find all rules matching a partial string on the rule name (like `win` to get all rules about `Windows`) they receive 0 matches. This is in contrast to the behavior they experience when searching available prebuilt "Elastic Rules", where partial name searches are available. This PR fixes this UX inconsistency by modifying the KQL output generated by the search query. Whereas previously a search for `"win"` would have generated the following KQL: ```sql (alert.attributes.name: "win" OR alert.attributes.params.index: "win" OR alert.attributes.params.threat.tactic.id: "win" OR ... ``` It now treats the `alert.attributes.name` differently, allowing it to match on partial terms (ie `*win*` instead of `"win"`) and using `.keyword` index for better special character support. ```sql (alert.attributes.name.keyword: *win* OR # <-- here alert.attributes.params.index: "win" OR alert.attributes.params.threat.tactic.id: "win" OR ... ``` ### 🕵️ BUT! .. We only do this for single term searches! Please note that this approach only applies to single term searches. For multiple term searches we maintain the "old" way of searching with quotations `"windows 10 patch"` instead of `*windows 10 patch*` or even `*windows* *10* *patch*`. The reasoning here is that since search results are NOT sorted by score, we want to avoid returning too many matches (wildcard searches match on _any_ combination of the terms, ie those with `windows` or `10` or `patch`) which would confuse the user just as they are trying to narrow down their search results!! (ie 🤔 why am I getting `Linux patch` rules when I'm asking for `windows 10 patch`??). ## How to test: To test this PR please checkout the relevant branch and run an instance of kibana/elasticsearch locally. 1. `Security App` > `Rules` > `Detection Rules (SIEM)` 2. A table of installed rules should appear. Remove all installed Elastic rules. 3. `Elastic Rules` (filter) > Tick to select all > `Select all X Rules` > `Bulk actions` > `Delete` > `Delete` 4. Go to `Add Elastic Rules` 5. `Search rules by name` > `win` > `Enter` 6. You should see 5 pages of results (~84 rules) 7. `Install All` 8. All Elastic rules have been installed > `Go back to installed Elastic rules` 9. Search by rule name > `win` > `Enter` 10. You should see 5 pages of results (~84 rules - same as in Step 6.) Please feel free to try some additional search queries. <details> <summary> ### 👉 Click for additional testing ideas </summary> Here are some single term searches: - `goog` - `proc` - `sql` (should include `Postgresql` and `MSSQL`) - `shell` (should include `Powershell`) - `inject` (should include `injection`) - `git` (should include `GitHub`) - `.exe` - `pub/sub` (exact matches only) - `user-agent` (exact matches only) - `/bin` - `CVE-2025` (should include partial matches) - `-` (should return matches with dash in the name like `user-agent`) - `_` (should return matches with dash in the name like `CAP_SYS_ADMIN`) - `|` (should return no matches - no elastic rules with this) And behavior for multiple term searches: - `AWS` (check the count), then `AWS IAM` (there should be less results for `AWS IAM`) - `pub/sub topic` (should be less than for `pub/sub`, note that `pub/sub top` partial match has no matches!) - `root` then `root cert` then `root certificate` (the second has no results, `root certificate` should only return exact matches) - `proc`, then `process`, then `process injection`, then `potential process injection` (each should return less results) </details> ## Screenshots ![497230260-e8badac1-85dd-41ff-b905-1a0a3d4bc53d](https://github.com/user-attachments/assets/69e39370-c31c-4f24-84d5-a84386929612) ## Special character support Note that by switching to wildcard searches (ie `*win*`) on the `.keyword` index and fully escaping special characters in KQL we'll **_ALSO_** be allowing special character searches on single search terms. For example, searching by `user-agent` will return results that only match `User-Agent` but not `user` or `agent` individually. Some other useful example of these approach are searches for: `Pub/Sub`, `CVE-2025`, `/bin`, `_`, `.exe`. This support, in addition for escaping the backslash `\` character will allow us to close the next TWO ISSUES in this epic 🥳 wohoo!! 👉 elastic#97094 (special chars in rule name) and, 👉 elastic#194066 (special chars in tags) <img width="800" alt="image" src="https://github.com/user-attachments/assets/5411d8e8-b3f7-4a3f-9863-c64cc2a7df2c" /> <img width="800" alt="image" src="https://github.com/user-attachments/assets/c3589add-9f80-4646-807a-3f0c8a2ec5a7" /> ### Testing special character support. In addition to the steps above (installing all elastic rules), we can: 1. Create a rule with special characters: ie `Rule with special chars (&, *, #, $, ?, >, @, \, /, ", ‘, {, [, ;)` 2. Try some single term matches: - `@` - `&` (additional elastic rules with this term should appear). - `*` - `"` - `:` - `>` - `{` - `\` (backslash, this used to break the search under elastic#97094) ## Risks These are some of the risks that could be identified by using this approach. ### 1. Dependencies that reuse query logic Note that the `searchTerm -> KQL` conversion for searching for rules is used a few places. - Installed Rules - Rule Monitoring - Bulk actions **Note**: All these paths are tested manually, and form part of the automated tests. ### 2. Performance (ie `allowLeadingWildcards`) We're using wildcard searches before and after the search term (ie `*win*`) in order to replicate the behavior across both 'prebuilt' and 'installed' rules tables. The wildcard AFTER the term (`win*` => matching terms like `Windows`) is no problem but the one BEFORE (`*win` => matching terms like `Darwin`) could create an issue. Our [KQL documentation](https://www.elastic.co/docs/reference/query-languages/kql#_filter_for_documents_using_wildcards) warns that Kibana UI Advanced Settings have `query:allowLeadingWildcards` turned off by default. This is for performance reasons as a leading wildcard can have a large impact when searching indexes that have millions of terms associated with them. <img width="2784" height="2120" alt="image" src="https://github.com/user-attachments/assets/9e0a754c-c30b-453e-a3ae-60a104b13fde" /> > By default, leading wildcards are not allowed for performance reasons. You can modify this with the [query:allowLeadingWildcards](https://www.elastic.co/docs/reference/kibana/advanced-settings#query-allowleadingwildcards) advanced setting. Please note that the [Query DSL docs also warn](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query#query-string-wildcard) about avoiding this approach. There is also more info and additional warnings under [Lucene API docs](https://lucene.apache.org/core/9_12_3/core/org/apache/lucene/search/WildcardQuery.html). The risk here is two pronged: 1. Users with a lot (millions?) of detection rules will likely have a degraded search experience as queries will take longer to execute. 2. Leading wildcards appear to be working by default (in contrast to what is stated in the documentation). But the mere _existence_ of various settings to avoid them ([`allowLeadingWildcards`](https://www.elastic.co/docs/reference/kibana/advanced-settings#query-allowleadingwildcards), [`allow_leading_wildcard`](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query#query-string-wildcard), [`analyze_wildcards`](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query#query-string-wildcard)) is a risk, because some users may have a special setup we've not been able to anticipate in our testing, leading to potential issues like this one: elastic#57828 ### Mitigating factors: 1. Please note that in the case of security detection rules, the prebuilt rules package is _only ~1500 rules_! Users do not manage millions of rules, they generally manage low thousands or even hundreds. We've tested 100K rules successfully and there was [_no perceivable difference in terms of search performance_](elastic#237496 (comment)). And this seems to be a realistic test when checking actual usage stats (our 10 largest users in Sep'25 had between 60-160K installed). Also, due to current limits on pagination and bulk action logic we would likely hit different kinds of problems here _before_ search performance becomes an issue. 2. We've tested all the documented ways to disable leading wildcards, including disabling them manually (under `Server Management > Advanced Settings`) and explicitly in the `kibana.yml`. None of them seemed to affect searches carried out on Saved Objects. This is because our solution uses the [`alerting`](http://github.com/elastic/kibana/tree/main/x-pack/platform/plugins/shared/alerting) plugin under the hood which [converts the filter to KQL without referring to any UI Advanced Settings](https://github.com/elastic/kibana/blob/91cab0e1369473846dc2712aa7dfe38b8580a9a5/x-pack/platform/plugins/shared/alerting/server/rules_client/common/build_kuery_node_filter.ts#L23). And since there is no explicit setting for `allowLeadingWildcards` the default setting of `true` gets applied instead [inside the `grammar.peggy` file](https://github.com/elastic/kibana/blob/91cab0e1369473846dc2712aa7dfe38b8580a9a5/src/platform/packages/shared/kbn-es-query/src/kuery/grammar/grammar.peggy#L12). ### Risks that were found acceptable In the search for any possible settings that might affect the rollout of changes under this PR [we did find a setting](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-wildcard-query#_allow_expensive_queries_7) inside `elasticsearch.yml` that causes problems with the proposed solution. **TL/DR** 👉 `search.allow_expensive_queries=false` breaks the search.💥🤯 <img width="600" alt="image" src="https://github.com/user-attachments/assets/8d523d15-f832-4488-8c78-4ed60c474d44" /> However we also found bigger problems, _this setting also prevents the installation of prebuilt rules_ (see below), which are a cornerstone of our security solution. <img width="600" alt="image" src="https://github.com/user-attachments/assets/2b8d91e8-29b9-4f06-9f76-6b1edb999fd1" /> In other words, the setting `search.allow_expensive_queries=true` has become _a de-facto requirement of Detection Rules_, that has not yet been documented. Hence we including it to the [documentation](https://www.elastic.co/docs/solutions/security/detect-and-alert/detections-requirements) as part of this PR. Note also that the errors here are not limited to detection rules. We found that the setting _also compromised or outright broke a lot of functionality in Kibana_ 💥🤯 (including Fleet, API Keys, Timelines, Saved Object search, Tags, Server Monitoring etc). More about this is [documented in this internal document](https://docs.google.com/document/d/1HLOXQZFcm1-KBj9DHTqwcF3wDdLOE6CcgUzqzZA2CAg/edit?tab=t.0). And in this [internal slack thread](https://elastic.slack.com/archives/C02HA9E8221/p1760694975799469). So we're assuming that most if not all of our users will have it set to `true`. ## Checklist Check the PR satisfies following conditions. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Follow the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. - [x] Changes have been socialized with the PM and rest of the team. - [x] All identified Risks have been properly documented and investigated. ([internal investigation](https://docs.google.com/document/d/1HLOXQZFcm1-KBj9DHTqwcF3wDdLOE6CcgUzqzZA2CAg/edit?tab=t.0)) - [x] New requirements added to the technical docs (PR [elastic#3543](elastic/docs-content#3543), see [here](https://www.elastic.co/docs/solutions/security/detect-and-alert/detections-requirements)) (cherry picked from commit 433902b)
1 parent 46cb114 commit e77ac65

File tree

8 files changed

+460
-46
lines changed

8 files changed

+460
-46
lines changed

x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.test.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,101 @@ describe('convertRulesFilterToKQL', () => {
1515
tags: [],
1616
};
1717

18-
it('returns empty string if filter options are empty', () => {
18+
it('returns empty string if filter is an empty string', () => {
1919
const kql = convertRulesFilterToKQL(filterOptions);
2020

2121
expect(kql).toBe('');
2222
});
2323

24+
it('returns empty string if filter contains only whitespace', () => {
25+
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: ' \n\t' });
26+
27+
expect(kql).toBe('');
28+
});
29+
30+
it('returns empty string if filter is undefined', () => {
31+
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: undefined });
32+
33+
expect(kql).toBe('');
34+
});
35+
2436
it('handles presence of "filter" properly', () => {
2537
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' });
2638

2739
expect(kql).toBe(
28-
'(alert.attributes.name: "foo" OR alert.attributes.params.index: "foo" OR alert.attributes.params.threat.tactic.id: "foo" OR alert.attributes.params.threat.tactic.name: "foo" OR alert.attributes.params.threat.technique.id: "foo" OR alert.attributes.params.threat.technique.name: "foo" OR alert.attributes.params.threat.technique.subtechnique.id: "foo" OR alert.attributes.params.threat.technique.subtechnique.name: "foo")'
40+
'(' +
41+
'alert.attributes.name.keyword: *foo* ' +
42+
'OR alert.attributes.params.index: "foo" ' +
43+
'OR alert.attributes.params.threat.tactic.id: "foo" ' +
44+
'OR alert.attributes.params.threat.tactic.name: "foo" ' +
45+
'OR alert.attributes.params.threat.technique.id: "foo" ' +
46+
'OR alert.attributes.params.threat.technique.name: "foo" ' +
47+
'OR alert.attributes.params.threat.technique.subtechnique.id: "foo" ' +
48+
'OR alert.attributes.params.threat.technique.subtechnique.name: "foo"' +
49+
')'
2950
);
3051
});
3152

32-
it('escapes "filter" value properly', () => {
33-
const kql = convertRulesFilterToKQL({ ...filterOptions, filter: '" OR (foo: bar)' });
53+
it('escapes "filter" value for single term searches', () => {
54+
const kql = convertRulesFilterToKQL({
55+
...filterOptions,
56+
filter: '"a<detection\\-rule*with)a<surprise:',
57+
});
3458

3559
expect(kql).toBe(
36-
'(alert.attributes.name: "\\" OR (foo: bar)" OR alert.attributes.params.index: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.name: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.name: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.subtechnique.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.subtechnique.name: "\\" OR (foo: bar)")'
60+
'(' +
61+
'alert.attributes.name.keyword: *\\"a\\<detection\\\\-rule\\*with\\)a\\<surprise\\:* ' +
62+
'OR alert.attributes.params.index: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
63+
'OR alert.attributes.params.threat.tactic.id: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
64+
'OR alert.attributes.params.threat.tactic.name: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
65+
'OR alert.attributes.params.threat.technique.id: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
66+
'OR alert.attributes.params.threat.technique.name: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
67+
'OR alert.attributes.params.threat.technique.subtechnique.id: "\\"a<detection\\\\-rule*with)a<surprise:" ' +
68+
'OR alert.attributes.params.threat.technique.subtechnique.name: "\\"a<detection\\\\-rule*with)a<surprise:"' +
69+
')'
3770
);
3871
});
3972

73+
it('allows partial name matches for single term searches', () => {
74+
const kql = convertRulesFilterToKQL({
75+
...filterOptions,
76+
filter: 'sql',
77+
});
78+
79+
expect(kql.startsWith('(alert.attributes.name.keyword: *sql*')).toBe(true);
80+
expect(kql).not.toContain('alert.attributes.name: "sql"');
81+
});
82+
83+
it('escapes "filter" value for multiple term searches', () => {
84+
const kql = convertRulesFilterToKQL({
85+
...filterOptions,
86+
filter: '"a <detection rule with)\\a< surprise:',
87+
});
88+
89+
expect(kql).toBe(
90+
'(' +
91+
'alert.attributes.name: "\\"a <detection rule with)\\\\a< surprise:" ' +
92+
'OR alert.attributes.params.index: "\\"a <detection rule with)\\\\a< surprise:" ' +
93+
'OR alert.attributes.params.threat.tactic.id: "\\"a <detection rule with)\\\\a< surprise:" ' +
94+
'OR alert.attributes.params.threat.tactic.name: "\\"a <detection rule with)\\\\a< surprise:" ' +
95+
'OR alert.attributes.params.threat.technique.id: "\\"a <detection rule with)\\\\a< surprise:" ' +
96+
'OR alert.attributes.params.threat.technique.name: "\\"a <detection rule with)\\\\a< surprise:" ' +
97+
'OR alert.attributes.params.threat.technique.subtechnique.id: "\\"a <detection rule with)\\\\a< surprise:" ' +
98+
'OR alert.attributes.params.threat.technique.subtechnique.name: "\\"a <detection rule with)\\\\a< surprise:"' +
99+
')'
100+
);
101+
});
102+
103+
it('allows only exact matching for multi-term searches', () => {
104+
const kql = convertRulesFilterToKQL({
105+
...filterOptions,
106+
filter: 'sql server',
107+
});
108+
109+
expect(kql.startsWith('(alert.attributes.name: "sql server"')).toBe(true);
110+
expect(kql).not.toContain('alert.attributes.name.keyword: *sql server*');
111+
});
112+
40113
it('handles presence of "showCustomRules" properly', () => {
41114
const kql = convertRulesFilterToKQL({ ...filterOptions, showCustomRules: true });
42115

@@ -74,7 +147,19 @@ describe('convertRulesFilterToKQL', () => {
74147
});
75148

76149
expect(kql).toBe(
77-
`(alert.attributes.name: "foo" OR alert.attributes.params.index: "foo" OR alert.attributes.params.threat.tactic.id: "foo" OR alert.attributes.params.threat.tactic.name: "foo" OR alert.attributes.params.threat.technique.id: "foo" OR alert.attributes.params.threat.technique.name: "foo" OR alert.attributes.params.threat.technique.subtechnique.id: "foo" OR alert.attributes.params.threat.technique.subtechnique.name: "foo") AND alert.attributes.params.immutable: true AND alert.attributes.tags:(\"tag1\" AND \"tag2\")`
150+
`(` +
151+
`alert.attributes.name.keyword: *foo* OR ` +
152+
`alert.attributes.params.index: "foo" OR ` +
153+
`alert.attributes.params.threat.tactic.id: "foo" OR ` +
154+
`alert.attributes.params.threat.tactic.name: "foo" OR ` +
155+
`alert.attributes.params.threat.technique.id: "foo" OR ` +
156+
`alert.attributes.params.threat.technique.name: "foo" OR ` +
157+
`alert.attributes.params.threat.technique.subtechnique.id: "foo" OR ` +
158+
`alert.attributes.params.threat.technique.subtechnique.name: "foo")` +
159+
` AND ` +
160+
`alert.attributes.params.immutable: true` +
161+
` AND ` +
162+
`alert.attributes.tags:(\"tag1\" AND \"tag2\")`
78163
);
79164
});
80165

x-pack/solutions/security/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
99
import type { RuleExecutionStatus } from '../../api/detection_engine';
1010
import { RuleCustomizationStatus, RuleExecutionStatusEnum } from '../../api/detection_engine';
11-
import { prepareKQLStringParam } from '../../utils/kql';
11+
import { fullyEscapeKQLStringParam, prepareKQLStringParam } from '../../utils/kql';
1212
import {
1313
ENABLED_FIELD,
1414
IS_CUSTOMIZED_FIELD,
@@ -60,7 +60,7 @@ export function convertRulesFilterToKQL({
6060
}: Partial<RulesFilterOptions>): string {
6161
const kql: string[] = [];
6262

63-
if (searchTerm?.length) {
63+
if (searchTerm?.trim().length) {
6464
kql.push(`(${convertRuleSearchTermToKQL(searchTerm)})`);
6565
}
6666

@@ -106,7 +106,6 @@ export function convertRulesFilterToKQL({
106106
}
107107

108108
const SEARCHABLE_RULE_ATTRIBUTES = [
109-
RULE_NAME_FIELD,
110109
RULE_PARAMS_FIELDS.INDEX,
111110
RULE_PARAMS_FIELDS.TACTIC_ID,
112111
RULE_PARAMS_FIELDS.TACTIC_NAME,
@@ -116,11 +115,36 @@ const SEARCHABLE_RULE_ATTRIBUTES = [
116115
RULE_PARAMS_FIELDS.SUBTECHNIQUE_NAME,
117116
];
118117

119-
export function convertRuleSearchTermToKQL(
120-
searchTerm: string,
121-
attributes = SEARCHABLE_RULE_ATTRIBUTES
122-
): string {
123-
return attributes.map((param) => `${param}: ${prepareKQLStringParam(searchTerm)}`).join(' OR ');
118+
/**
119+
* Build KQL search terms.
120+
*
121+
* Note that RULE_NAME_FIELD is special, for single term searches
122+
* it includes partial matches, supporting special characters.
123+
*
124+
* Ie - "sql" =KQL=> *sql* --matches--> sql, Postgreslq, SQLCMD.EXE
125+
* - "sql:" =KQL=> *sql\:* --matches--> sql:x64, but NOT sql_x64
126+
*
127+
* Whereas the rest of the fields, and multiple term searches,
128+
* we use exact term match with quotations.
129+
*
130+
* Ie - "sql" =KQL=> "sql" --matches--> sql server, but NOT mssql or SQLCMD.EXE
131+
*
132+
* @param searchTerm search term (ie from the search bar)
133+
* @returns KQL String
134+
*/
135+
export function convertRuleSearchTermToKQL(searchTerm: string): string {
136+
const searchableConditions = SEARCHABLE_RULE_ATTRIBUTES.map(
137+
(attribute) => `${attribute}: ${prepareKQLStringParam(searchTerm)}`
138+
);
139+
const escapedTerm = fullyEscapeKQLStringParam(searchTerm);
140+
const isSingleTerm = escapedTerm.split(' ').length === 1;
141+
let ruleNameCondition = '';
142+
if (isSingleTerm) {
143+
ruleNameCondition = `${RULE_NAME_FIELD}.keyword: *${escapedTerm}*`;
144+
} else {
145+
ruleNameCondition = `${RULE_NAME_FIELD}: ${prepareKQLStringParam(searchTerm)}`;
146+
}
147+
return [ruleNameCondition].concat(searchableConditions).join(' OR ');
124148
}
125149

126150
export function convertRuleTagsToKQL(tags: string[]): string {

x-pack/solutions/security/plugins/security_solution/common/utils/kql.test.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
* 2.0.
66
*/
77

8-
import { escapeKQLStringParam, prepareKQLParam, prepareKQLStringParam } from './kql';
8+
import {
9+
escapeKQLStringParam,
10+
prepareKQLParam,
11+
prepareKQLStringParam,
12+
fullyEscapeKQLStringParam,
13+
} from './kql';
914

10-
const testCases = [
15+
const partialEscapeTestCases = [
1116
['does NOT remove white spaces quotes', ' netcat', ' netcat'],
1217
['escapes quotes', 'I said, "Hello."', 'I said, \\"Hello.\\"'],
1318
[
1419
'should escape special characters',
1520
`This \\ has (a lot of) <special> characters, don't you *think*? "Yes."`,
16-
`This \\ has (a lot of) <special> characters, don't you *think*? \\"Yes.\\"`,
21+
`This \\\\ has (a lot of) <special> characters, don't you *think*? \\"Yes.\\"`,
1722
],
1823
['does NOT escape keywords', 'foo and bar or baz not qux', 'foo and bar or baz not qux'],
1924
[
@@ -42,10 +47,15 @@ const testCases = [
4247
'This\nhas\tnewlines\r\nwith\ttabs',
4348
'This\\nhas\\tnewlines\\r\\nwith\\ttabs',
4449
],
50+
[
51+
'escapes backslashes at the end of the string',
52+
'Try not to break the search\\',
53+
'Try not to break the search\\\\',
54+
],
4555
];
4656

4757
describe('prepareKQLParam', () => {
48-
it.each(testCases)('%s', (_, input, expected) => {
58+
it.each(partialEscapeTestCases)('%s', (_, input, expected) => {
4959
expect(prepareKQLParam(input)).toBe(`"${expected}"`);
5060
});
5161

@@ -65,13 +75,44 @@ describe('prepareKQLParam', () => {
6575
});
6676

6777
describe('prepareKQLStringParam', () => {
68-
it.each(testCases)('%s', (_, input, expected) => {
78+
it.each(partialEscapeTestCases)('%s', (_, input, expected) => {
6979
expect(prepareKQLStringParam(input)).toBe(`"${expected}"`);
7080
});
7181
});
7282

7383
describe('escapeKQLStringParam', () => {
74-
it.each(testCases)('%s', (_, input, expected) => {
84+
it.each(partialEscapeTestCases)('%s', (_, input, expected) => {
7585
expect(escapeKQLStringParam(input)).toBe(expected);
7686
});
7787
});
88+
89+
const fullyEscapeTestCases = [
90+
['escapes quotes, but keeps commas and dots', 'I said, "Hello."', 'I said, \\"Hello.\\"'],
91+
[
92+
'should cleanup special characters',
93+
`This \\ has (a lot of) <special> characters, don't you *think*? "Yes."`,
94+
`This \\\\ has \\(a lot of\\) \\<special\\> characters, don't you \\*think\\*? \\"Yes.\\"`,
95+
],
96+
[
97+
'should cleanup special characters and trim whitespace',
98+
`a "user-agent+ \t }with \n a *\\:(surprise{) \t`,
99+
`a \\"user-agent+ \\}with a \\*\\\\\\:\\(surprise\\{\\)`,
100+
],
101+
[
102+
"should keep certain characters that are not problematic (.,'&^%$#)",
103+
`\t some characters are ok to use .,'&^%$#-+_=|/!`,
104+
`some characters are ok to use .,'&^%$#-+_=|/!`,
105+
],
106+
['does NOT escape keywords', 'foo and bar or baz not qux', 'foo and bar or baz not qux'],
107+
[
108+
'It can also handle creepy unicode',
109+
'It can also handle c̶̛̫̜̞̜͕̼̱̘̤̔̿̽́̉̓̋͠͠r̵̨̨̳̯̬͔̰̙͕̲̭̞̈́͒́͋͛̕͝ȩ̷̨͖̻͓̭̮͙͖̬̿͛͐̀̐̄̀͆̾̀̏̓͗̇͘͜ḛ̷̲̖͚̼͇̖̖̩̤͖̪̠̍͂̆͒̂̿̐p̸̹͇̲͇̬̞̞̐̃̎̍͂͐̐́̋̂͝y̶̧̝͔̙̮͖̹̯̺͇̞̰̹͉̏͗̿͑̿͆̐̈́ unicode',
110+
'It can also handle c̶̛̫̜̞̜͕̼̱̘̤̔̿̽́̉̓̋͠͠r̵̨̨̳̯̬͔̰̙͕̲̭̞̈́͒́͋͛̕͝ȩ̷̨͖̻͓̭̮͙͖̬̿͛͐̀̐̄̀͆̾̀̏̓͗̇͘͜ḛ̷̲̖͚̼͇̖̖̩̤͖̪̠̍͂̆͒̂̿̐p̸̹͇̲͇̬̞̞̐̃̎̍͂͐̐́̋̂͝y̶̧̝͔̙̮͖̹̯̺͇̞̰̹͉̏͗̿͑̿͆̐̈́ unicode',
111+
],
112+
];
113+
114+
describe('fullyEscapeKQLStringParam', () => {
115+
it.each(fullyEscapeTestCases)('%s', (_, input, expected) => {
116+
expect(fullyEscapeKQLStringParam(input)).toBe(expected);
117+
});
118+
});

x-pack/solutions/security/plugins/security_solution/common/utils/kql.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,58 @@ export function prepareKQLStringParam(value: string): string {
3333
}
3434

3535
/**
36-
* Escapes string param intended to be passed to KQL. As official docs
37-
* [here](https://www.elastic.co/guide/en/kibana/current/kuery-query.html) say
38-
* `Certain characters must be escaped by a backslash (unless surrounded by quotes).` and
39-
* `You must escape following characters: \():<>"*`.
36+
* Partially escapes string param intended to be passed to KQL.
4037
*
41-
* This function assumes the value is surrounded by quotes so it escapes quotes, tabs and new line symbols.
38+
* This is intended to be used for KQL search terms surrounded by quotes.
39+
* It escapes quotes, backslashes, tabs and new line symbols.
4240
*
4341
* @param param a string param value intended to be passed to KQL
44-
* @returns an escaped string param value
42+
* @returns a partially escaped KQL string param
4543
*/
4644
export function escapeKQLStringParam(value = ''): string {
47-
return escapeStringValue(value);
45+
return partiallyEscapeStringValue(value);
4846
}
4947

5048
const escapeQuotes = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string
5149

50+
const escapeBackslash = (val: string) => val.replace(/\\/g, '\\$&');
51+
5252
const escapeTabs = (val: string) =>
5353
val.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
5454

55-
const escapeStringValue = flow(escapeQuotes, escapeTabs);
55+
const partiallyEscapeStringValue = flow(escapeBackslash, escapeQuotes, escapeTabs);
56+
57+
/**
58+
* Fully escapes special characters to improve matching on KQL.
59+
*
60+
* As per official docs [here](https://www.elastic.co/guide/en/kibana/current/kuery-query.html)
61+
* `Certain characters must be escaped by a backslash (unless surrounded by quotes).` and
62+
* `You must escape following characters: \():<>"*`.
63+
*
64+
* This is intended to be used on KQL search terms WITHOUT quotes.
65+
*
66+
* @example "a \"user-agent+ \t }with \n a *\:(surprise!) " => "a \\\\"user-agent+ \\}with a \\*\\\\:\\(surprise!\\)"
67+
*
68+
* @see https://www.elastic.co/docs/reference/query-languages/kql
69+
*
70+
* @param param a string param value intended to be passed to KQL
71+
* @returns a fully escaped KQL string value
72+
*/
73+
export function fullyEscapeKQLStringParam(value = ''): string {
74+
return fullyEscapeStringValue(value);
75+
}
76+
77+
const SPECIAL_KQL_CHARACTERS = '\\(){}:<>"*';
78+
const SPECIAL_KQL_CHARACTERS_REGEX = new RegExp(
79+
`[${SPECIAL_KQL_CHARACTERS.split('').join('\\')}]`,
80+
'g'
81+
);
82+
83+
const escapeSpecialKQLCharacters = (val: string) =>
84+
val.replace(SPECIAL_KQL_CHARACTERS_REGEX, '\\$&');
85+
86+
const simplifyWhitespace = (val: string) => val.replace(/\s+/g, ' ');
87+
88+
const trim = (val: string) => val.trim();
89+
90+
const fullyEscapeStringValue = flow(escapeSpecialKQLCharacters, simplifyWhitespace, trim);

0 commit comments

Comments
 (0)