Skip to content

Commit

Permalink
[SIEM] Adds conditional linking within the application for machine le…
Browse files Browse the repository at this point in the history
…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
FrankHassanabad authored Jul 10, 2019
1 parent ea3360e commit 40a1bde
Show file tree
Hide file tree
Showing 20 changed files with 1,051 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ describe('EmptyValue', () => {
expect(toJson(wrapper)).toMatchSnapshot();
});

describe('#getEmptyValue', () =>
test('should return an empty value', () => expect(getEmptyValue()).toBe('--')));
describe('#getEmptyValue', () => {
test('should return an empty value', () => expect(getEmptyValue()).toBe('--'));
});

describe('#getEmptyString', () => {
test('should turn into an empty string place holder', () => {
Expand Down
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))'
);
});
});
});
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;
};
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(['']);
});
});
});
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(',');
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>
));
Loading

0 comments on commit 40a1bde

Please sign in to comment.