diff --git a/x-pack/plugins/security_solution/common/rule_fields.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_fields.ts similarity index 100% rename from x-pack/plugins/security_solution/common/rule_fields.ts rename to x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_fields.ts diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.test.ts new file mode 100644 index 0000000000000..d40aadadb184f --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { convertRulesFilterToKQL } from './rule_filtering'; + +describe('convertRulesFilterToKQL', () => { + const filterOptions = { + filter: '', + showCustomRules: false, + showElasticRules: false, + tags: [], + }; + + it('returns empty string if filter options are empty', () => { + const kql = convertRulesFilterToKQL(filterOptions); + + expect(kql).toBe(''); + }); + + it('handles presence of "filter" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' }); + + expect(kql).toBe( + '(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")' + ); + }); + + it('escapes "filter" value properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, filter: '" OR (foo: bar)' }); + + expect(kql).toBe( + '(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)")' + ); + }); + + it('handles presence of "showCustomRules" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, showCustomRules: true }); + + expect(kql).toBe(`alert.attributes.params.immutable: false`); + }); + + it('handles presence of "showElasticRules" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, showElasticRules: true }); + + expect(kql).toBe(`alert.attributes.params.immutable: true`); + }); + + it('handles presence of "showElasticRules" and "showCustomRules" at the same time properly', () => { + const kql = convertRulesFilterToKQL({ + ...filterOptions, + showElasticRules: true, + showCustomRules: true, + }); + + expect(kql).toBe(''); + }); + + it('handles presence of "tags" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, tags: ['tag1', 'tag2'] }); + + expect(kql).toBe('alert.attributes.tags:("tag1" AND "tag2")'); + }); + + it('handles combination of different properties properly', () => { + const kql = convertRulesFilterToKQL({ + ...filterOptions, + filter: 'foo', + showElasticRules: true, + tags: ['tag1', 'tag2'], + }); + + expect(kql).toBe( + `(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\")` + ); + }); + + it('handles presence of "excludeRuleTypes" properly', () => { + const kql = convertRulesFilterToKQL({ + ...filterOptions, + excludeRuleTypes: ['machine_learning', 'saved_query'], + }); + + expect(kql).toBe('NOT alert.attributes.params.type: ("machine_learning" OR "saved_query")'); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts new file mode 100644 index 0000000000000..b8fe93efc722a --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts @@ -0,0 +1,113 @@ +/* + * 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 { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import { RuleExecutionStatus } from '../../api/detection_engine'; +import { prepareKQLStringParam } from '../../utils/kql'; +import { + ENABLED_FIELD, + LAST_RUN_OUTCOME_FIELD, + PARAMS_IMMUTABLE_FIELD, + PARAMS_TYPE_FIELD, + RULE_NAME_FIELD, + RULE_PARAMS_FIELDS, + TAGS_FIELD, +} from './rule_fields'; + +export const KQL_FILTER_IMMUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: true`; +export const KQL_FILTER_MUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: false`; +export const KQL_FILTER_ENABLED_RULES = `${ENABLED_FIELD}: true`; +export const KQL_FILTER_DISABLED_RULES = `${ENABLED_FIELD}: false`; + +interface RulesFilterOptions { + filter: string; + showCustomRules: boolean; + showElasticRules: boolean; + enabled: boolean; + tags: string[]; + excludeRuleTypes: Type[]; + ruleExecutionStatus: RuleExecutionStatus; +} + +/** + * Convert rules filter options object to KQL query + * + * @param filterOptions desired filters (e.g. filter/sortField/sortOrder) + * + * @returns KQL string + */ +export function convertRulesFilterToKQL({ + filter: searchTerm, + showCustomRules, + showElasticRules, + enabled, + tags, + excludeRuleTypes = [], + ruleExecutionStatus, +}: Partial): string { + const kql: string[] = []; + + if (searchTerm?.length) { + kql.push(`(${convertRuleSearchTermToKQL(searchTerm)})`); + } + + if (showCustomRules && showElasticRules) { + // if both showCustomRules && showElasticRules selected we omit filter, as it includes all existing rules + } else if (showElasticRules) { + kql.push(KQL_FILTER_IMMUTABLE_RULES); + } else if (showCustomRules) { + kql.push(KQL_FILTER_MUTABLE_RULES); + } + + if (enabled !== undefined) { + kql.push(enabled ? KQL_FILTER_ENABLED_RULES : KQL_FILTER_DISABLED_RULES); + } + + if (tags?.length) { + kql.push(convertRuleTagsToKQL(tags)); + } + + if (excludeRuleTypes.length) { + kql.push(`NOT ${convertRuleTypesToKQL(excludeRuleTypes)}`); + } + + if (ruleExecutionStatus === RuleExecutionStatus.succeeded) { + kql.push(`${LAST_RUN_OUTCOME_FIELD}: "succeeded"`); + } else if (ruleExecutionStatus === RuleExecutionStatus['partial failure']) { + kql.push(`${LAST_RUN_OUTCOME_FIELD}: "warning"`); + } else if (ruleExecutionStatus === RuleExecutionStatus.failed) { + kql.push(`${LAST_RUN_OUTCOME_FIELD}: "failed"`); + } + + return kql.join(' AND '); +} + +const SEARCHABLE_RULE_ATTRIBUTES = [ + RULE_NAME_FIELD, + RULE_PARAMS_FIELDS.INDEX, + RULE_PARAMS_FIELDS.TACTIC_ID, + RULE_PARAMS_FIELDS.TACTIC_NAME, + RULE_PARAMS_FIELDS.TECHNIQUE_ID, + RULE_PARAMS_FIELDS.TECHNIQUE_NAME, + RULE_PARAMS_FIELDS.SUBTECHNIQUE_ID, + RULE_PARAMS_FIELDS.SUBTECHNIQUE_NAME, +]; + +export function convertRuleSearchTermToKQL( + searchTerm: string, + attributes = SEARCHABLE_RULE_ATTRIBUTES +): string { + return attributes.map((param) => `${param}: ${prepareKQLStringParam(searchTerm)}`).join(' OR '); +} + +export function convertRuleTagsToKQL(tags: string[]): string { + return `${TAGS_FIELD}:(${tags.map(prepareKQLStringParam).join(' AND ')})`; +} + +export function convertRuleTypesToKQL(ruleTypes: Type[]): string { + return `${PARAMS_TYPE_FIELD}: (${ruleTypes.map(prepareKQLStringParam).join(' OR ')})`; +} diff --git a/x-pack/plugins/security_solution/common/utils/kql.test.ts b/x-pack/plugins/security_solution/common/utils/kql.test.ts index 799dc51770a52..665a1f7b5329c 100644 --- a/x-pack/plugins/security_solution/common/utils/kql.test.ts +++ b/x-pack/plugins/security_solution/common/utils/kql.test.ts @@ -5,85 +5,73 @@ * 2.0. */ -import { convertRulesFilterToKQL } from './kql'; - -describe('convertRulesFilterToKQL', () => { - const filterOptions = { - filter: '', - showCustomRules: false, - showElasticRules: false, - tags: [], - }; - - it('returns empty string if filter options are empty', () => { - const kql = convertRulesFilterToKQL(filterOptions); - - expect(kql).toBe(''); - }); - - it('handles presence of "filter" properly', () => { - const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' }); - - expect(kql).toBe( - '(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")' - ); +import { escapeKQLStringParam, prepareKQLParam, prepareKQLStringParam } from './kql'; + +const testCases = [ + ['does NOT remove white spaces quotes', ' netcat', ' netcat'], + ['escapes quotes', 'I said, "Hello."', 'I said, \\"Hello.\\"'], + [ + 'should escape special characters', + `This \\ has (a lot of) characters, don't you *think*? "Yes."`, + `This \\ has (a lot of) characters, don't you *think*? \\"Yes.\\"`, + ], + ['does NOT escape keywords', 'foo and bar or baz not qux', 'foo and bar or baz not qux'], + [ + 'does NOT escape keywords next to each other', + 'foo and bar or not baz', + 'foo and bar or not baz', + ], + [ + 'does NOT escape keywords without surrounding spaces', + 'And this has keywords, or does it not?', + 'And this has keywords, or does it not?', + ], + [ + 'does NOT escape uppercase keywords', + 'And this has keywords, or does it not?', + 'And this has keywords, or does it not?', + ], + ['does NOT escape uppercase keywords', 'foo AND bar', 'foo AND bar'], + [ + 'escapes special characters and NOT keywords', + 'Hello, "world", and to meet you!', + 'Hello, \\"world\\", and to meet you!', + ], + [ + 'escapes newlines and tabs', + 'This\nhas\tnewlines\r\nwith\ttabs', + 'This\\nhas\\tnewlines\\r\\nwith\\ttabs', + ], +]; + +describe('prepareKQLParam', () => { + it.each(testCases)('%s', (_, input, expected) => { + expect(prepareKQLParam(input)).toBe(`"${expected}"`); }); - it('escapes "filter" value properly', () => { - const kql = convertRulesFilterToKQL({ ...filterOptions, filter: '" OR (foo: bar)' }); + it('stringifies numbers without enclosing by quotes', () => { + const input = 10; + const expected = '10'; - expect(kql).toBe( - '(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\\)")' - ); + expect(prepareKQLParam(input)).toBe(expected); }); - it('handles presence of "showCustomRules" properly', () => { - const kql = convertRulesFilterToKQL({ ...filterOptions, showCustomRules: true }); + it('stringifies booleans without enclosing by quotes', () => { + const input = true; + const expected = 'true'; - expect(kql).toBe(`alert.attributes.params.immutable: false`); + expect(prepareKQLParam(input)).toBe(expected); }); +}); - it('handles presence of "showElasticRules" properly', () => { - const kql = convertRulesFilterToKQL({ ...filterOptions, showElasticRules: true }); - - expect(kql).toBe(`alert.attributes.params.immutable: true`); - }); - - it('handles presence of "showElasticRules" and "showCustomRules" at the same time properly', () => { - const kql = convertRulesFilterToKQL({ - ...filterOptions, - showElasticRules: true, - showCustomRules: true, - }); - - expect(kql).toBe(''); - }); - - it('handles presence of "tags" properly', () => { - const kql = convertRulesFilterToKQL({ ...filterOptions, tags: ['tag1', 'tag2'] }); - - expect(kql).toBe('alert.attributes.tags:("tag1" AND "tag2")'); - }); - - it('handles combination of different properties properly', () => { - const kql = convertRulesFilterToKQL({ - ...filterOptions, - filter: 'foo', - showElasticRules: true, - tags: ['tag1', 'tag2'], - }); - - expect(kql).toBe( - `(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\")` - ); +describe('prepareKQLStringParam', () => { + it.each(testCases)('%s', (_, input, expected) => { + expect(prepareKQLStringParam(input)).toBe(`"${expected}"`); }); +}); - it('handles presence of "excludeRuleTypes" properly', () => { - const kql = convertRulesFilterToKQL({ - ...filterOptions, - excludeRuleTypes: ['machine_learning', 'saved_query'], - }); - - expect(kql).toBe('NOT alert.attributes.params.type: ("machine_learning" OR "saved_query")'); +describe('escapeKQLStringParam', () => { + it.each(testCases)('%s', (_, input, expected) => { + expect(escapeKQLStringParam(input)).toBe(expected); }); }); diff --git a/x-pack/plugins/security_solution/common/utils/kql.ts b/x-pack/plugins/security_solution/common/utils/kql.ts index cf55d16e91c9d..7ab8a47ef5c22 100644 --- a/x-pack/plugins/security_solution/common/utils/kql.ts +++ b/x-pack/plugins/security_solution/common/utils/kql.ts @@ -5,111 +5,51 @@ * 2.0. */ -import { escapeKuery } from '@kbn/es-query'; -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { RuleExecutionStatus } from '../api/detection_engine'; -import { - ENABLED_FIELD, - LAST_RUN_OUTCOME_FIELD, - PARAMS_IMMUTABLE_FIELD, - PARAMS_TYPE_FIELD, - RULE_NAME_FIELD, - RULE_PARAMS_FIELDS, - TAGS_FIELD, -} from '../rule_fields'; +import { flow, isString } from 'lodash/fp'; -export const KQL_FILTER_IMMUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: true`; -export const KQL_FILTER_MUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: false`; -export const KQL_FILTER_ENABLED_RULES = `${ENABLED_FIELD}: true`; -export const KQL_FILTER_DISABLED_RULES = `${ENABLED_FIELD}: false`; - -interface RulesFilterOptions { - filter: string; - showCustomRules: boolean; - showElasticRules: boolean; - enabled: boolean; - tags: string[]; - excludeRuleTypes: Type[]; - ruleExecutionStatus: RuleExecutionStatus; +/** + * Preparing an arbitrary KQL query param by quoting and escaping string values, stringifying non string values. + * + * See https://www.elastic.co/guide/en/kibana/current/kuery-query.html + * + * @param value + * @returns + */ +export function prepareKQLParam(value: string | number | boolean): string { + return isString(value) ? prepareKQLStringParam(value) : `${value}`; } /** - * Convert rules filter options object to KQL query + * Prepares a string KQL query param by wrapping the value in quotes and making sure + * the quotes, tabs and new line symbols inside are escaped. * - * @param filterOptions desired filters (e.g. filter/sortField/sortOrder) + * See https://www.elastic.co/guide/en/kibana/current/kuery-query.html * - * @returns KQL string + * @param value a string param value intended to be passed to KQL + * @returns a quoted and escaped string param value */ -export function convertRulesFilterToKQL({ - filter: searchTerm, - showCustomRules, - showElasticRules, - enabled, - tags, - excludeRuleTypes = [], - ruleExecutionStatus, -}: Partial): string { - const kql: string[] = []; - - if (searchTerm?.length) { - kql.push(`(${convertRuleSearchTermToKQL(searchTerm)})`); - } - - if (showCustomRules && showElasticRules) { - // if both showCustomRules && showElasticRules selected we omit filter, as it includes all existing rules - } else if (showElasticRules) { - kql.push(KQL_FILTER_IMMUTABLE_RULES); - } else if (showCustomRules) { - kql.push(KQL_FILTER_MUTABLE_RULES); - } - - if (enabled !== undefined) { - kql.push(enabled ? KQL_FILTER_ENABLED_RULES : KQL_FILTER_DISABLED_RULES); - } - - if (tags?.length) { - kql.push(convertRuleTagsToKQL(tags)); - } - - if (excludeRuleTypes.length) { - kql.push(`NOT ${convertRuleTypesToKQL(excludeRuleTypes)}`); - } - - if (ruleExecutionStatus === RuleExecutionStatus.succeeded) { - kql.push(`${LAST_RUN_OUTCOME_FIELD}: "succeeded"`); - } else if (ruleExecutionStatus === RuleExecutionStatus['partial failure']) { - kql.push(`${LAST_RUN_OUTCOME_FIELD}: "warning"`); - } else if (ruleExecutionStatus === RuleExecutionStatus.failed) { - kql.push(`${LAST_RUN_OUTCOME_FIELD}: "failed"`); - } - - return kql.join(' AND '); +export function prepareKQLStringParam(value: string): string { + return `"${escapeKQLStringParam(value)}"`; } -const SEARCHABLE_RULE_ATTRIBUTES = [ - RULE_NAME_FIELD, - RULE_PARAMS_FIELDS.INDEX, - RULE_PARAMS_FIELDS.TACTIC_ID, - RULE_PARAMS_FIELDS.TACTIC_NAME, - RULE_PARAMS_FIELDS.TECHNIQUE_ID, - RULE_PARAMS_FIELDS.TECHNIQUE_NAME, - RULE_PARAMS_FIELDS.SUBTECHNIQUE_ID, - RULE_PARAMS_FIELDS.SUBTECHNIQUE_NAME, -]; - -export function convertRuleSearchTermToKQL( - searchTerm: string, - attributes = SEARCHABLE_RULE_ATTRIBUTES -): string { - return attributes.map((param) => `${param}: "${escapeKuery(searchTerm)}"`).join(' OR '); +/** + * Escapes string param intended to be passed to KQL. As official docs + * [here](https://www.elastic.co/guide/en/kibana/current/kuery-query.html) say + * `Certain characters must be escaped by a backslash (unless surrounded by quotes).` and + * `You must escape following characters: \():<>"*`. + * + * This function assumes the value is surrounded by quotes so it escapes quotes, tabs and new line symbols. + * + * @param param a string param value intended to be passed to KQL + * @returns an escaped string param value + */ +export function escapeKQLStringParam(value = ''): string { + return escapeStringValue(value); } -export function convertRuleTagsToKQL(tags: string[]): string { - return `${TAGS_FIELD}:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})`; -} +const escapeQuotes = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string -export function convertRuleTypesToKQL(ruleTypes: Type[]): string { - return `${PARAMS_TYPE_FIELD}: (${ruleTypes - .map((ruleType) => `"${escapeKuery(ruleType)}"`) - .join(' OR ')})`; -} +const escapeTabs = (val: string) => + val.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); + +const escapeStringValue = flow(escapeQuotes, escapeTabs); diff --git a/x-pack/plugins/security_solution/public/common/lib/kuery/index.test.ts b/x-pack/plugins/security_solution/public/common/lib/kuery/index.test.ts index b6958b681f794..1a3cfe0eb9f08 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kuery/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kuery/index.test.ts @@ -6,65 +6,9 @@ */ import expect from '@kbn/expect'; -import { convertToBuildEsQuery, escapeKuery } from '.'; +import { convertToBuildEsQuery } from '.'; import { mockIndexPattern } from '../../mock'; -describe('Kuery escape', () => { - it('should not remove white spaces quotes', () => { - const value = ' netcat'; - const expected = ' netcat'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should escape quotes', () => { - const value = 'I said, "Hello."'; - const expected = 'I said, \\"Hello.\\"'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should escape special characters', () => { - const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`; - const expected = `This \\ has (a lot of) characters, don't you *think*? \\"Yes.\\"`; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should NOT escape keywords', () => { - const value = 'foo and bar or baz not qux'; - const expected = 'foo and bar or baz not qux'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should NOT escape keywords next to each other', () => { - const value = 'foo and bar or not baz'; - const expected = 'foo and bar or not baz'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should not escape keywords without surrounding spaces', () => { - const value = 'And this has keywords, or does it not?'; - const expected = 'And this has keywords, or does it not?'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should NOT escape uppercase keywords', () => { - const value = 'foo AND bar'; - const expected = 'foo AND bar'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should escape special characters and NOT keywords', () => { - const value = 'Hello, "world", and to meet you!'; - const expected = 'Hello, \\"world\\", and to meet you!'; - expect(escapeKuery(value)).to.be(expected); - }); - - it('should escape newlines and tabs', () => { - const value = 'This\nhas\tnewlines\r\nwith\ttabs'; - const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs'; - expect(escapeKuery(value)).to.be(expected); - }); -}); - describe('convertToBuildEsQuery', () => { /** * All the fields in this query, except for `@timestamp`, diff --git a/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts b/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts index 4960b76b361cf..3fefc26076a12 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kuery/index.ts @@ -12,8 +12,9 @@ import { FilterStateStore, buildEsQuery, } from '@kbn/es-query'; -import { flow, get, isEmpty, isString } from 'lodash/fp'; +import { get, isEmpty } from 'lodash/fp'; import memoizeOne from 'memoize-one'; +import { prepareKQLParam } from '../../../../common/utils/kql'; import type { BrowserFields } from '../../../../common/search_strategy'; import type { DataProvider, DataProvidersAnd } from '../../../../common/types'; import { DataProviderType } from '../../../../common/api/timeline'; @@ -35,33 +36,6 @@ export interface CombineQueries { kqlMode: string; } -export const escapeQueryValue = ( - val: PrimitiveOrArrayOfPrimitives = '' -): PrimitiveOrArrayOfPrimitives => { - if (isString(val)) { - if (isEmpty(val)) { - return '""'; - } - return `"${escapeKuery(val)}"`; - } - - return val; -}; - -const escapeWhitespace = (val: string) => - val.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n'); - -// See the SpecialCharacter rule in kuery.peg -const escapeSpecialCharacters = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string - -// See the Keyword rule in kuery.peg -// I do not think that we need that anymore since we are doing a full match_phrase all the time now => return `"${escapeKuery(val)}"`; -// const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); - -// const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); - -export const escapeKuery = flow(escapeSpecialCharacters, escapeWhitespace); - export const convertKueryToElasticSearchQuery = ( kueryExpression: string, indexPattern?: DataViewBase @@ -161,9 +135,9 @@ const buildQueryMatch = ( : checkIfFieldTypeIsDate(dataProvider.queryMatch.field, browserFields) ? convertDateFieldToQuery(dataProvider.queryMatch.field, dataProvider.queryMatch.value) : `${dataProvider.queryMatch.field} : ${ - isNumber(dataProvider.queryMatch.value) + Array.isArray(dataProvider.queryMatch.value) ? dataProvider.queryMatch.value - : escapeQueryValue(dataProvider.queryMatch.value) + : prepareKQLParam(dataProvider.queryMatch.value) }` : checkIfFieldTypeIsNested(dataProvider.queryMatch.field, browserFields) ? convertNestedFieldToExistQuery(dataProvider.queryMatch.field, browserFields) diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index cf584bb87c207..9e09a1754a04d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -194,7 +194,7 @@ describe('Detections Rules API', () => { method: 'GET', query: { filter: - '(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\\)")', + '(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)")', page: 1, per_page: 20, sort_field: 'enabled', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 9a0345faf4c0f..c90b48860bbf6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -16,7 +16,7 @@ import type { ActionResult } from '@kbn/actions-plugin/server'; import type { BulkInstallPackagesResponse } from '@kbn/fleet-plugin/common'; import { epmRouteService } from '@kbn/fleet-plugin/common'; import type { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; -import { convertRulesFilterToKQL } from '../../../../common/utils/kql'; +import { convertRulesFilterToKQL } from '../../../../common/detection_engine/rule_management/rule_filtering'; import type { UpgradeSpecificRulesRequest, PerformRuleUpgradeResponseBody, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index 24ae37bf8e6d9..a7c5e35ff3341 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -12,7 +12,7 @@ import type { Toast } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { euiThemeVars } from '@kbn/ui-theme'; import React, { useCallback } from 'react'; -import { convertRulesFilterToKQL } from '../../../../../../common/utils/kql'; +import { convertRulesFilterToKQL } from '../../../../../../common/detection_engine/rule_management/rule_filtering'; import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; import type { BulkActionEditPayload } from '../../../../../../common/api/detection_engine/rule_management'; import { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts index cc7524234feef..a538940cfa7a8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts @@ -8,12 +8,12 @@ import type { DryRunResult } from '../types'; import type { FilterOptions } from '../../../../../rule_management/logic/types'; +import { convertRulesFilterToKQL } from '../../../../../../../common/detection_engine/rule_management/rule_filtering'; import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants'; import { prepareSearchParams } from './prepare_search_params'; -import { convertRulesFilterToKQL } from '../../../../../../../common/utils/kql'; -jest.mock('../../../../../../../common/utils/kql', () => ({ +jest.mock('../../../../../../../common/detection_engine/rule_management/rule_filtering', () => ({ convertRulesFilterToKQL: jest.fn().mockReturnValue('str'), })); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts index c868a0b34270b..b8f84fc2bb544 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { convertRulesFilterToKQL } from '../../../../../../../common/utils/kql'; +import { convertRulesFilterToKQL } from '../../../../../../../common/detection_engine/rule_management/rule_filtering'; import type { QueryOrIds } from '../../../../../rule_management/logic'; import type { DryRunResult } from '../types'; import type { FilterOptions } from '../../../../../rule_management/logic/types'; diff --git a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts index 77cb6f5f54a76..2707d32788b18 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts @@ -5,39 +5,10 @@ * 2.0. */ -import { getHostDetailsEventsKqlQueryExpression, getHostDetailsPageFilters } from './helpers'; +import { getHostDetailsPageFilters } from './helpers'; import type { Filter } from '@kbn/es-query'; describe('hosts page helpers', () => { - describe('getHostDetailsEventsKqlQueryExpression', () => { - const filterQueryExpression = 'user.name: "root"'; - const hostName = 'foo'; - - it('combines the filterQueryExpression and hostname when both are NOT empty', () => { - expect(getHostDetailsEventsKqlQueryExpression({ filterQueryExpression, hostName })).toEqual( - 'user.name: "root" and host.name: "foo"' - ); - }); - - it('returns just the filterQueryExpression when it is NOT empty, but hostname is empty', () => { - expect( - getHostDetailsEventsKqlQueryExpression({ filterQueryExpression, hostName: '' }) - ).toEqual('user.name: "root"'); - }); - - it('returns just the hostname when filterQueryExpression is empty, but hostname is NOT empty', () => { - expect( - getHostDetailsEventsKqlQueryExpression({ filterQueryExpression: '', hostName }) - ).toEqual('host.name: "foo"'); - }); - - it('returns an empty string when both the filterQueryExpression and hostname are empty', () => { - expect( - getHostDetailsEventsKqlQueryExpression({ filterQueryExpression: '', hostName: '' }) - ).toEqual(''); - }); - }); - describe('getHostDetailsPageFilters', () => { it('correctly constructs pageFilters for the given hostName', () => { const expected: Filter[] = [ diff --git a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts index feea9db7ef496..e219fdda541da 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts +++ b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts @@ -6,24 +6,6 @@ */ import type { Filter } from '@kbn/es-query'; -import { escapeQueryValue } from '../../../../common/lib/kuery'; - -/** Returns the kqlQueryExpression for the `Events` widget on the `Host Details` page */ -export const getHostDetailsEventsKqlQueryExpression = ({ - filterQueryExpression, - hostName, -}: { - filterQueryExpression: string; - hostName: string; -}): string => { - if (filterQueryExpression.length) { - return `${filterQueryExpression}${ - hostName.length ? ` and host.name: ${escapeQueryValue(hostName)}` : '' - }`; - } else { - return hostName.length ? `host.name: ${escapeQueryValue(hostName)}` : ''; - } -}; export const getHostDetailsPageFilters = (hostName: string): Filter[] => [ { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 6ea097e5a3144..72be8b6110ae4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; +import { isEmpty, isNumber } from 'lodash/fp'; import { elementOrChildrenHasFocus, @@ -15,11 +15,10 @@ import { stopPropagationAndPreventDefault, } from '@kbn/timelines-plugin/public'; +import { prepareKQLParam, prepareKQLStringParam } from '../../../../common/utils/kql'; import { assertUnreachable } from '../../../../common/utility_types'; import type { BrowserFields } from '../../../common/containers/source'; import { - escapeQueryValue, - isNumber, convertDateFieldToQuery, checkIfFieldTypeIsDate, convertNestedFieldToQuery, @@ -263,7 +262,7 @@ export const buildIsQueryMatch = ({ } else if (checkIfFieldTypeIsDate(field, browserFields)) { return convertDateFieldToQuery(field, value); } else { - return `${field} : ${isNumber(value) ? value : escapeQueryValue(value)}`; + return `${field} : ${prepareKQLParam(value)}`; } }; @@ -291,7 +290,7 @@ export const buildIsOneOfQueryMatch = ({ const trimmedField = field.trim(); if (value.length) { return `${trimmedField} : (${value - .map((item) => (isNumber(item) ? Number(item) : `${escapeQueryValue(String(item).trim())}`)) + .map((item) => (isNumber(item) ? item : prepareKQLStringParam(String(item).trim()))) .join(' OR ')})`; } return `${trimmedField} : ''`; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/handle_coverage_overview_request.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/handle_coverage_overview_request.ts index 67900c2296355..563d33dd64f4d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/handle_coverage_overview_request.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/coverage_overview/handle_coverage_overview_request.ts @@ -7,7 +7,7 @@ import type { SanitizedRule } from '@kbn/alerting-plugin/common'; import type { RulesClient } from '@kbn/alerting-plugin/server'; -import { convertRulesFilterToKQL } from '../../../../../../../common/utils/kql'; +import { convertRulesFilterToKQL } from '../../../../../../../common/detection_engine/rule_management/rule_filtering'; import type { CoverageOverviewRequestBody, CoverageOverviewResponse, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/get_existing_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/get_existing_prepackaged_rules.ts index ee5d6082a9fdb..24b2954547e40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/get_existing_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/get_existing_prepackaged_rules.ts @@ -6,10 +6,11 @@ */ import type { RulesClient } from '@kbn/alerting-plugin/server'; + import { KQL_FILTER_IMMUTABLE_RULES, KQL_FILTER_MUTABLE_RULES, -} from '../../../../../../common/utils/kql'; +} from '../../../../../../common/detection_engine/rule_management/rule_filtering'; import { withSecuritySpan } from '../../../../../utils/with_security_span'; import { findRules } from './find_rules'; import type { RuleAlertType } from '../../../rule_schema'; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/rules_table_filtering.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/rules_table_filtering.cy.ts index 61cd1a33499ad..eaa67b859b6c0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/rules_table_filtering.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rules_table/rules_table_filtering.cy.ts @@ -13,6 +13,12 @@ import { expectNumberOfRulesShownOnPage, } from '../../../../tasks/rule_filters'; +import { + expectManagementTableRules, + filterByTags, + unselectTags, +} from '../../../../tasks/alerts_detection_rules'; + import { createRule, waitForRulesToFinishExecution } from '../../../../tasks/api_calls/rules'; import { deleteIndex, @@ -103,4 +109,45 @@ describe('Rules table: filtering', { tags: ['@ess', '@serverless'] }, () => { expectRulesWithExecutionStatus(1, 'Failed'); }); }); + + describe('Tags filter', () => { + beforeEach(() => { + createRule( + getNewRule({ + name: 'Rule 1', + tags: [], + }) + ); + + createRule( + getNewRule({ + name: 'Rule 2', + tags: ['simpleTag'], + }) + ); + + createRule( + getNewRule({ + name: 'Rule 3', + tags: ['category:tag'], + }) + ); + }); + + it('filter by different tags', () => { + visitSecurityDetectionRulesPage(); + + expectManagementTableRules(['Rule 1', 'Rule 2', 'Rule 3']); + + filterByTags(['simpleTag']); + + expectManagementTableRules(['Rule 2']); + + unselectTags(); + + filterByTags(['category:tag']); + + expectManagementTableRules(['Rule 3']); + }); + }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts index 3170084e39778..5f66f5513ed17 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts_detection_rules.ts @@ -147,6 +147,20 @@ export const filterByTags = (tags: string[]) => { for (const tag of tags) { cy.get(RULES_TAGS_FILTER_POPOVER).contains(tag).click(); } + + // close the popover + cy.get(RULES_TAGS_FILTER_BTN).click(); +}; + +export const unselectTags = () => { + cy.get(RULES_TAGS_FILTER_BTN).click(); + + cy.get(RULES_TAGS_FILTER_POPOVER) + .find('[aria-checked="true"]') + .each((el) => cy.wrap(el).click()); + + // close the popover + cy.get(RULES_TAGS_FILTER_BTN).click(); }; export const waitForRuleExecution = (name: string) => {