-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[SIEM] Adds conditional linking within the application for machine le…
…arning jobs (#40547) ## Summary Fixes these issues when drilling down from an ML job to a SIEM page: * An influencer or entity from a variable such as $host.name$ could be a comma separated value * An influencer or entity from a variable such as $host.name$ could be still a variable and not replaced in which case it causes invalid URL's and KQL * You could end up with multiple influencers or entities when you drill down to a link such as host details we have more than one host name where it would be better to redirect to the host overview and show the multiple hosts as part of the KQL. * https://github.com/elastic/ingest-dev/issues/564 You prefix your jobs with the label of `ml-hosts` or `ml-network` instead of `hosts` or `network` and allow the router to re-direct depending on what is given from the machine learning. <img width="1230" alt="Screen Shot 2019-07-10 at 1 06 48 AM" src="https://user-images.githubusercontent.com/1151048/60947979-24affa80-a2af-11e9-8c9c-3790beb08db4.png"> If you have multiple hosts then it will re-direct you to the hosts overview page instead of the details page with all of the hosts separated by OR clauses. Likewise if you have multiple networks it will redirect you to the network page with `source.ip` `destination.ip` or'ed together with each network IP. Both of those will keep the AND clause of any influencers or entities you carry with it: <img width="775" alt="Screen Shot 2019-07-10 at 12 55 36 AM" src="https://user-images.githubusercontent.com/1151048/60948138-76f11b80-a2af-11e9-9f74-e5566cb74d4b.png"> <img width="1214" alt="Screen Shot 2019-07-10 at 12 55 50 AM" src="https://user-images.githubusercontent.com/1151048/60948120-68a2ff80-a2af-11e9-8813-6138e34df550.png"> If you have any variables that are not flushed with values it will take you to the overview pages as well and not try to keep you on a details page with an dollar signed value. ## Caveats * This does not have KQL support for operators such as these `<`, `<=` or `>` mixed in. It only checks and works with the `and`, `or`, and `not` * This only works with a very specific subset of KQL in which it expects the values to be wrapped in double quotes such as `host.name: "host-value"` * This is tested with the soon to be shipped SIEM jobs and not with ad-hoc created jobs. * The approach is that it tries to _only_ change or manipulate the URL's RISON and KQL if it has to manipulate them. If it does not detect any comma separated values or any variables not replaced it will leave the RISON and KQL alone. ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) ~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~ - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ ### For maintainers ~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ - [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
- Loading branch information
1 parent
ea3360e
commit 40a1bde
Showing
20 changed files
with
1,051 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
...ck/legacy/plugins/siem/public/components/ml/conditional_links/add_entities_to_kql.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { entityToKql, entitiesToKql, addEntitiesToKql } from './add_entities_to_kql'; | ||
|
||
describe('add_entities_to_kql', () => { | ||
describe('#entityToKql', () => { | ||
test('returns empty string with no entity names defined and an empty entity string', () => { | ||
const entity = entityToKql([], ''); | ||
expect(entity).toEqual(''); | ||
}); | ||
|
||
test('returns empty string with no entity names defined and an entity defined', () => { | ||
const entity = entityToKql([], 'some-value'); | ||
expect(entity).toEqual(''); | ||
}); | ||
|
||
test('returns empty string with a single entity name defined but an empty entity string as a single empty double quote', () => { | ||
const entity = entityToKql(['host.name'], ''); | ||
expect(entity).toEqual('host.name: ""'); | ||
}); | ||
|
||
test('returns KQL with a single entity name defined', () => { | ||
const entity = entityToKql(['host.name'], 'some-value'); | ||
expect(entity).toEqual('host.name: "some-value"'); | ||
}); | ||
|
||
test('returns empty string with two entity names defined but an empty entity string as a single empty double quote', () => { | ||
const entity = entityToKql(['source.ip', 'destination.ip'], ''); | ||
expect(entity).toEqual('(source.ip: "" or destination.ip: "")'); | ||
}); | ||
|
||
test('returns KQL with two entity names defined', () => { | ||
const entity = entityToKql(['source.ip', 'destination.ip'], 'some-value'); | ||
expect(entity).toEqual('(source.ip: "some-value" or destination.ip: "some-value")'); | ||
}); | ||
|
||
test('returns empty string with three entity names defined but an empty entity string as a single empty double quote', () => { | ||
const entity = entityToKql(['source.ip', 'destination.ip', 'process.name'], ''); | ||
expect(entity).toEqual('(source.ip: "" or destination.ip: "" or process.name: "")'); | ||
}); | ||
|
||
test('returns KQL with three entity names defined', () => { | ||
const entity = entityToKql(['source.ip', 'destination.ip', 'process.name'], 'some-value'); | ||
expect(entity).toEqual( | ||
'(source.ip: "some-value" or destination.ip: "some-value" or process.name: "some-value")' | ||
); | ||
}); | ||
}); | ||
|
||
describe('#entitiesToKql', () => { | ||
test('returns empty string with no entity names defined and empty entity strings', () => { | ||
const entity = entitiesToKql([], []); | ||
expect(entity).toEqual(''); | ||
}); | ||
|
||
test('returns empty string with a single entity name defined but an empty entity string as a single empty double quote', () => { | ||
const entity = entitiesToKql(['host.name'], ['']); | ||
expect(entity).toEqual('host.name: ""'); | ||
}); | ||
|
||
test('returns KQL with a single entity name defined', () => { | ||
const entity = entitiesToKql(['host.name'], ['some-value']); | ||
expect(entity).toEqual('host.name: "some-value"'); | ||
}); | ||
|
||
test('returns KQL with two entity names defined but one single value', () => { | ||
const entity = entitiesToKql(['source.ip', 'destination.ip'], ['some-value']); | ||
expect(entity).toEqual('(source.ip: "some-value" or destination.ip: "some-value")'); | ||
}); | ||
|
||
test('returns KQL with two entity values defined', () => { | ||
const entity = entitiesToKql(['host.name'], ['some-value-1', 'some-value-2']); | ||
expect(entity).toEqual('host.name: "some-value-1" or host.name: "some-value-2"'); | ||
}); | ||
|
||
test('returns KQL with two entity names and values defined', () => { | ||
const entity = entitiesToKql( | ||
['destination.ip', 'source.ip'], | ||
['some-value-1', 'some-value-2'] | ||
); | ||
expect(entity).toEqual( | ||
'(destination.ip: "some-value-1" or source.ip: "some-value-1") or (destination.ip: "some-value-2" or source.ip: "some-value-2")' | ||
); | ||
}); | ||
}); | ||
|
||
describe('#addEntitiesToKql', () => { | ||
test('returns same kql if no entity names or values were defined', () => { | ||
const entity = addEntitiesToKql( | ||
[], | ||
[], | ||
'(filterQuery:(expression:\'process.name : ""\',kind:kuery))' | ||
); | ||
expect(entity).toEqual('(filterQuery:(expression:\'process.name : ""\',kind:kuery))'); | ||
}); | ||
|
||
test('returns kql with no "and" clause if the KQL expression is not defined ', () => { | ||
const entity = addEntitiesToKql( | ||
['host.name'], | ||
['host-1'], | ||
"(filterQuery:(expression:'',kind:kuery))" | ||
); | ||
expect(entity).toEqual('(filterQuery:(expression:\'(host.name: "host-1")\',kind:kuery))'); | ||
}); | ||
|
||
test('returns kql with "and" clause separating the two if the KQL expression is defined', () => { | ||
const entity = addEntitiesToKql( | ||
['host.name'], | ||
['host-1'], | ||
'(filterQuery:(expression:\'process.name : ""\',kind:kuery))' | ||
); | ||
expect(entity).toEqual( | ||
'(filterQuery:(expression:\'(host.name: "host-1") and (process.name : "")\',kind:kuery))' | ||
); | ||
}); | ||
|
||
test('returns KQL that is not a Rison Object "as is" with no changes', () => { | ||
const entity = addEntitiesToKql(['host.name'], ['host-1'], 'I am some invalid value'); | ||
expect(entity).toEqual('I am some invalid value'); | ||
}); | ||
|
||
test('returns kql with "and" clause separating the two with multiple entity names and a single value', () => { | ||
const entity = addEntitiesToKql( | ||
['source.ip', 'destination.ip'], | ||
['127.0.0.1'], | ||
'(filterQuery:(expression:\'process.name : ""\',kind:kuery))' | ||
); | ||
expect(entity).toEqual( | ||
'(filterQuery:(expression:\'((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1")) and (process.name : "")\',kind:kuery))' | ||
); | ||
}); | ||
|
||
test('returns kql with "and" clause separating the two with multiple entity names and a multiple values', () => { | ||
const entity = addEntitiesToKql( | ||
['source.ip', 'destination.ip'], | ||
['127.0.0.1', '255.255.255.255'], | ||
'(filterQuery:(expression:\'process.name : ""\',kind:kuery))' | ||
); | ||
expect(entity).toEqual( | ||
'(filterQuery:(expression:\'((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1") or (source.ip: "255.255.255.255" or destination.ip: "255.255.255.255")) and (process.name : "")\',kind:kuery))' | ||
); | ||
}); | ||
|
||
test('returns kql with "and" clause separating the two with single entity name and multiple values', () => { | ||
const entity = addEntitiesToKql( | ||
['host.name'], | ||
['host-name-1', 'host-name-2'], | ||
'(filterQuery:(expression:\'process.name : ""\',kind:kuery))' | ||
); | ||
expect(entity).toEqual( | ||
'(filterQuery:(expression:\'(host.name: "host-name-1" or host.name: "host-name-2") and (process.name : "")\',kind:kuery))' | ||
); | ||
}); | ||
}); | ||
}); |
58 changes: 58 additions & 0 deletions
58
x-pack/legacy/plugins/siem/public/components/ml/conditional_links/add_entities_to_kql.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { RisonValue, encode } from 'rison-node'; | ||
import { decodeRison, isRisonObject, isRegularString } from './rison_helpers'; | ||
|
||
export const entityToKql = (entityNames: string[], entity: string): string => { | ||
if (entityNames.length === 1) { | ||
return `${entityNames[0]}: "${entity}"`; | ||
} else { | ||
return entityNames.reduce((accum, entityName, index, array) => { | ||
if (index === 0) { | ||
return `(${entityName}: "${entity}"`; | ||
} else if (index === array.length - 1) { | ||
return `${accum} or ${entityName}: "${entity}")`; | ||
} else { | ||
return `${accum} or ${entityName}: "${entity}"`; | ||
} | ||
}, ''); | ||
} | ||
}; | ||
|
||
export const entitiesToKql = (entityNames: string[], entities: string[]): string => { | ||
return entities.reduce((accum, entity, index) => { | ||
const entityKql = entityToKql(entityNames, entity); | ||
if (index === 0) { | ||
return entityKql; | ||
} else { | ||
return `${accum} or ${entityKql}`; | ||
} | ||
}, ''); | ||
}; | ||
|
||
export const addEntitiesToKql = ( | ||
entityNames: string[], | ||
entities: string[], | ||
kqlQuery: string | ||
): string => { | ||
const value: RisonValue = decodeRison(kqlQuery); | ||
if (isRisonObject(value)) { | ||
const filterQuery = value.filterQuery; | ||
if (isRisonObject(filterQuery)) { | ||
if (isRegularString(filterQuery.expression)) { | ||
const entitiesKql = entitiesToKql(entityNames, entities); | ||
if (filterQuery.expression !== '' && entitiesKql !== '') { | ||
filterQuery.expression = `(${entitiesKql}) and (${filterQuery.expression})`; | ||
} else if (filterQuery.expression === '' && entitiesKql !== '') { | ||
filterQuery.expression = `(${entitiesKql})`; | ||
} | ||
return encode(value); | ||
} | ||
} | ||
} | ||
return kqlQuery; | ||
}; |
51 changes: 51 additions & 0 deletions
51
x-pack/legacy/plugins/siem/public/components/ml/conditional_links/entity_helpers.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { emptyEntity, multipleEntities, getMultipleEntities } from './entity_helpers'; | ||
|
||
describe('entity_helpers', () => { | ||
describe('#emptyEntity', () => { | ||
test('returns empty entity if the string is empty', () => { | ||
expect(emptyEntity('')).toEqual(true); | ||
}); | ||
|
||
test('returns empty entity if the string is blank spaces', () => { | ||
expect(emptyEntity(' ')).toEqual(true); | ||
}); | ||
|
||
test('returns empty entity if is an entity that starts and ends with a $', () => { | ||
expect(emptyEntity('$host.name$')).toEqual(true); | ||
}); | ||
}); | ||
|
||
describe('#multipleEntities', () => { | ||
test('returns multiple entities if they are a separated string and there are two', () => { | ||
expect(multipleEntities('a,b')).toEqual(true); | ||
}); | ||
|
||
test('returns multiple entities if they are a separated string and there are three', () => { | ||
expect(multipleEntities('a,b,c')).toEqual(true); | ||
}); | ||
|
||
test('returns false for multiple entities if they are a single string', () => { | ||
expect(multipleEntities('a')).toEqual(false); | ||
}); | ||
}); | ||
|
||
describe('#getMultipleEntities', () => { | ||
test('returns multiple entities if they are a separated string and there are two', () => { | ||
expect(getMultipleEntities('a,b')).toEqual(['a', 'b']); | ||
}); | ||
|
||
test('returns single entity', () => { | ||
expect(getMultipleEntities('a')).toEqual(['a']); | ||
}); | ||
|
||
test('returns empty entity', () => { | ||
expect(getMultipleEntities('')).toEqual(['']); | ||
}); | ||
}); | ||
}); |
12 changes: 12 additions & 0 deletions
12
x-pack/legacy/plugins/siem/public/components/ml/conditional_links/entity_helpers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
export const emptyEntity = (entity: string): boolean => | ||
entity.trim() === '' || (entity.startsWith('$') && entity.endsWith('$')); | ||
|
||
export const multipleEntities = (entity: string): boolean => entity.split(',').length > 1; | ||
|
||
export const getMultipleEntities = (entity: string): string[] => entity.split(','); |
88 changes: 88 additions & 0 deletions
88
...acy/plugins/siem/public/components/ml/conditional_links/ml_host_conditional_container.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import React from 'react'; | ||
|
||
import { match as RouteMatch, Redirect, Route, Switch } from 'react-router-dom'; | ||
import { QueryString } from 'ui/utils/query_string'; | ||
import { addEntitiesToKql } from './add_entities_to_kql'; | ||
import { replaceKQLParts } from './replace_kql_parts'; | ||
import { emptyEntity, multipleEntities, getMultipleEntities } from './entity_helpers'; | ||
import { replaceKqlQueryLocationForHostPage } from './replace_kql_query_location_for_host_page'; | ||
|
||
interface MlHostConditionalProps { | ||
match: RouteMatch<{}>; | ||
location: Location; | ||
} | ||
|
||
interface QueryStringType { | ||
'?_g': string; | ||
kqlQuery: string | null; | ||
timerange: string | null; | ||
} | ||
|
||
export const MlHostConditionalContainer = React.memo<MlHostConditionalProps>(({ match }) => ( | ||
<Switch> | ||
<Route | ||
strict | ||
exact | ||
path={match.url} | ||
render={({ location }) => { | ||
const queryStringDecoded: QueryStringType = QueryString.decode( | ||
location.search.substring(1) | ||
); | ||
if (queryStringDecoded.kqlQuery != null) { | ||
queryStringDecoded.kqlQuery = replaceKQLParts(queryStringDecoded.kqlQuery); | ||
} | ||
const reEncoded = QueryString.encode(queryStringDecoded); | ||
return <Redirect to={`/hosts?${reEncoded}`} />; | ||
}} | ||
/> | ||
<Route | ||
path={`${match.url}/:hostName`} | ||
render={({ | ||
location, | ||
match: { | ||
params: { hostName }, | ||
}, | ||
}) => { | ||
const queryStringDecoded: QueryStringType = QueryString.decode( | ||
location.search.substring(1) | ||
); | ||
if (queryStringDecoded.kqlQuery != null) { | ||
queryStringDecoded.kqlQuery = replaceKQLParts(queryStringDecoded.kqlQuery); | ||
} | ||
if (emptyEntity(hostName)) { | ||
if (queryStringDecoded.kqlQuery != null) { | ||
queryStringDecoded.kqlQuery = replaceKqlQueryLocationForHostPage( | ||
queryStringDecoded.kqlQuery | ||
); | ||
} | ||
const reEncoded = QueryString.encode(queryStringDecoded); | ||
return <Redirect to={`/hosts?${reEncoded}`} />; | ||
} else if (multipleEntities(hostName)) { | ||
const hosts: string[] = getMultipleEntities(hostName); | ||
if (queryStringDecoded.kqlQuery != null) { | ||
queryStringDecoded.kqlQuery = addEntitiesToKql( | ||
['host.name'], | ||
hosts, | ||
queryStringDecoded.kqlQuery | ||
); | ||
queryStringDecoded.kqlQuery = replaceKqlQueryLocationForHostPage( | ||
queryStringDecoded.kqlQuery | ||
); | ||
} | ||
const reEncoded = QueryString.encode(queryStringDecoded); | ||
return <Redirect to={`/hosts?${reEncoded}`} />; | ||
} else { | ||
const reEncoded = QueryString.encode(queryStringDecoded); | ||
return <Redirect to={`/hosts/${hostName}?${reEncoded}`} />; | ||
} | ||
}} | ||
/> | ||
<Redirect from="/ml-hosts/" to="/ml-hosts" /> | ||
</Switch> | ||
)); |
Oops, something went wrong.