[Security Solutions][Detection Engine] Adds e2e tests for constant_keyword data type (#101234)

## Summary

Adds e2e tests for the `constant_keyword` regular `keyword` to compare between the two. Bugs found with these is one where we do not copy `constant_keyword` fields into signals which I added `.skip` to the tests now.

Tested these rule types:
* KQL
* EQL
* Threshold

For the mappings of the `constant_keyword` I use both the `constant_keyword` and the field `alias` like so:

```json
{
  "properties": {
    "@timestamp": {
      "type": "date"
    },
    "data_stream": {
      "properties": {
        "dataset": {
          "type": "constant_keyword",
          "value": "dataset_name_1"
        },
        "module": {
          "type": "constant_keyword",
          "value": "module_name_1"
        }
      }
    },
    "event": {
      "properties": {
        "category": {
          "type": "keyword"
        },
        "dataset": {
          "type": "alias",
          "path": "data_stream.dataset"
        },
        "module": {
          "type": "alias",
          "path": "data_stream.module"
        }
      }
    }
  }
}
``` 

To ensure we can detect against fields. I also mix them with regular const keyword fields in another index to ensure they work also in mixed use cases.

### Checklist

- [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
This commit is contained in:
Frank Hassanabad 2021-06-04 14:05:51 -06:00 committed by GitHub
parent 8a793f50eb
commit 598e63b532
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 713 additions and 0 deletions

View file

@ -49,5 +49,11 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
describe('', function () {
loadTestFile(require.resolve('./exception_operators_data_types/index'));
});
// That split here enable us on using a different ciGroup to run the tests
// listed on ./keyword_family/index
describe('', function () {
loadTestFile(require.resolve('./keyword_family/index'));
});
});
};

View file

@ -0,0 +1,11 @@
These are tests for the [keyword](https://www.elastic.co/guide/en/elasticsearch/reference/7.12/keyword.html) family where we test
* keyword
* const keyword
* alias fields against each one
Against mock rules which contain the ECS values of:
* event.module
* even.dataset
This is to ensure that if you have field aliases we will still correctly have detections occur. This also ensures that if you have
`keyword` mixed with `const keyword` across multiple indexes we will still have detections occur.

View file

@ -0,0 +1,161 @@
/*
* 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 expect from '@kbn/expect';
import {
EqlCreateSchema,
ThresholdCreateSchema,
} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
createRule,
createSignalsIndex,
deleteAllAlerts,
deleteSignalsIndex,
getRuleForSignalTesting,
getSignalsById,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
} from '../../../utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
interface EventModule {
module: string;
dataset: string;
}
describe('Rule detects against a keyword of event.dataset', () => {
beforeEach(async () => {
await createSignalsIndex(supertest);
await esArchiver.load('rule_keyword_family/const_keyword');
});
afterEach(async () => {
await deleteSignalsIndex(supertest);
await deleteAllAlerts(supertest);
await esArchiver.unload('rule_keyword_family/const_keyword');
});
describe('"kql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset" and have 4 signals', async () => {
const rule = {
...getRuleForSignalTesting(['const_keyword']),
query: 'event.dataset: "dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
expect(signalsOpen.hits.hits.length).to.eql(4);
});
// TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source
it.skip('should copy the dataset_name_1 from the index into the signal', async () => {
const rule = {
...getRuleForSignalTesting(['const_keyword']),
query: 'event.dataset: "dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"eql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset" and have 4 signals', async () => {
const rule: EqlCreateSchema = {
...getRuleForSignalTesting(['const_keyword']),
rule_id: 'eql-rule',
type: 'eql',
language: 'eql',
query: 'any where event.dataset=="dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
expect(signalsOpen.hits.hits.length).to.eql(4);
});
// TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source
it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => {
const rule: EqlCreateSchema = {
...getRuleForSignalTesting(['const_keyword']),
rule_id: 'eql-rule',
type: 'eql',
language: 'eql',
query: 'any where event.dataset=="dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"threshold" rule type', async () => {
it('should detect the "dataset_name_1" from "event.dataset"', async () => {
const rule: ThresholdCreateSchema = {
...getRuleForSignalTesting(['const_keyword']),
rule_id: 'threshold-rule',
type: 'threshold',
language: 'kuery',
query: '*:*',
threshold: {
field: 'event.dataset',
value: 1,
},
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 1, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => hit._source.signal.threshold_result ?? null)
.sort();
expect(hits).to.eql([
{
count: 4,
from: '1900-01-01T00:00:00.000Z',
terms: [
{
field: 'event.dataset',
value: 'dataset_name_1',
},
],
},
]);
});
});
});
};

View file

@ -0,0 +1,21 @@
/*
* 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 { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ loadTestFile }: FtrProviderContext): void => {
describe('Detection keyword family data types', function () {
describe('', function () {
this.tags('ciGroup11');
loadTestFile(require.resolve('./keyword'));
loadTestFile(require.resolve('./const_keyword'));
loadTestFile(require.resolve('./keyword_mixed_with_const'));
});
});
};

View file

@ -0,0 +1,131 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
createRule,
createSignalsIndex,
deleteAllAlerts,
deleteSignalsIndex,
getRuleForSignalTesting,
getSignalsById,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
} from '../../../utils';
import {
EqlCreateSchema,
ThresholdCreateSchema,
} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
interface EventModule {
module: string;
dataset: string;
}
describe('Rule detects against a keyword of event.dataset', () => {
beforeEach(async () => {
await createSignalsIndex(supertest);
await esArchiver.load('rule_keyword_family/keyword');
});
afterEach(async () => {
await deleteSignalsIndex(supertest);
await deleteAllAlerts(supertest);
await esArchiver.unload('rule_keyword_family/keyword');
});
describe('"kql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset"', async () => {
const rule = {
...getRuleForSignalTesting(['keyword']),
query: 'event.dataset: "dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"eql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset"', async () => {
const rule: EqlCreateSchema = {
...getRuleForSignalTesting(['keyword']),
rule_id: 'eql-rule',
type: 'eql',
language: 'eql',
query: 'any where event.dataset=="dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 4, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"threshold" rule type', async () => {
it('should detect the "dataset_name_1" from "event.dataset"', async () => {
const rule: ThresholdCreateSchema = {
...getRuleForSignalTesting(['keyword']),
rule_id: 'threshold-rule',
type: 'threshold',
language: 'kuery',
query: '*:*',
threshold: {
field: 'event.dataset',
value: 1,
},
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 1, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => hit._source.signal.threshold_result ?? null)
.sort();
expect(hits).to.eql([
{
count: 4,
from: '1900-01-01T00:00:00.000Z',
terms: [
{
field: 'event.dataset',
value: 'dataset_name_1',
},
],
},
]);
});
});
});
};

View file

@ -0,0 +1,171 @@
/*
* 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 expect from '@kbn/expect';
import {
EqlCreateSchema,
ThresholdCreateSchema,
} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
createRule,
createSignalsIndex,
deleteAllAlerts,
deleteSignalsIndex,
getRuleForSignalTesting,
getSignalsById,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
} from '../../../utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
interface EventModule {
module: string;
dataset: string;
}
describe('Rule detects against a keyword and constant_keyword of event.dataset', () => {
beforeEach(async () => {
await createSignalsIndex(supertest);
await esArchiver.load('rule_keyword_family/const_keyword');
await esArchiver.load('rule_keyword_family/keyword');
});
afterEach(async () => {
await deleteSignalsIndex(supertest);
await deleteAllAlerts(supertest);
await esArchiver.unload('rule_keyword_family/const_keyword');
await esArchiver.unload('rule_keyword_family/keyword');
});
describe('"kql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset" and have 8 signals, 4 from each index', async () => {
const rule = {
...getRuleForSignalTesting(['keyword', 'const_keyword']),
query: 'event.dataset: "dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 8, [id]);
const signalsOpen = await getSignalsById(supertest, id);
expect(signalsOpen.hits.hits.length).to.eql(8);
});
// TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source
it.skip('should copy the dataset_name_1 from the index into the signal', async () => {
const rule = {
...getRuleForSignalTesting(['keyword', 'const_keyword']),
query: 'event.dataset: "dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 8, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"eql" rule type', () => {
it('should detect the "dataset_name_1" from "event.dataset" and have 8 signals, 4 from each index', async () => {
const rule: EqlCreateSchema = {
...getRuleForSignalTesting(['keyword', 'const_keyword']),
rule_id: 'eql-rule',
type: 'eql',
language: 'eql',
query: 'any where event.dataset=="dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 8, [id]);
const signalsOpen = await getSignalsById(supertest, id);
expect(signalsOpen.hits.hits.length).to.eql(8);
});
// TODO: Fix this bug and make this work. We currently do not write out the dataset name when it is not in _source
it.skip('should copy the "dataset_name_1" from "event.dataset"', async () => {
const rule: EqlCreateSchema = {
...getRuleForSignalTesting(['keyword', 'const_keyword']),
rule_id: 'eql-rule',
type: 'eql',
language: 'eql',
query: 'any where event.dataset=="dataset_name_1"',
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 8, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => (hit._source.event as EventModule).dataset)
.sort();
expect(hits).to.eql([
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
'dataset_name_1',
]);
});
});
describe('"threshold" rule type', async () => {
it('should detect the "dataset_name_1" from "event.dataset"', async () => {
const rule: ThresholdCreateSchema = {
...getRuleForSignalTesting(['keyword', 'const_keyword']),
rule_id: 'threshold-rule',
type: 'threshold',
language: 'kuery',
query: '*:*',
threshold: {
field: 'event.dataset',
value: 1,
},
};
const { id } = await createRule(supertest, rule);
await waitForRuleSuccessOrStatus(supertest, id);
await waitForSignalsToBePresent(supertest, 1, [id]);
const signalsOpen = await getSignalsById(supertest, id);
const hits = signalsOpen.hits.hits
.map((hit) => hit._source.signal.threshold_result ?? null)
.sort();
expect(hits).to.eql([
{
count: 8,
from: '1900-01-01T00:00:00.000Z',
terms: [
{
field: 'event.dataset',
value: 'dataset_name_1',
},
],
},
]);
});
});
});
};

View file

@ -0,0 +1,20 @@
Within this folder is input test data for tests within the folder:
```ts
x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family
```
where these are small ECS compliant input indexes that try to express tests that exercise different parts of
the detection engine around creating and validating that the keyword family and field aliases all will work
with the detection engine. These indexes might contain extra fields or different fields but should not directly
clash with ECS or minimally clash. Nothing is stopping anyone from being ECS strict and not having additional
extra fields but the extra fields and mappings are to just try and keep these tests simple and small.
Most of these tests center around the two fields of:
* event.module
* event.dataset
To ensure that if mix and match between `keyword`, `const keyword` and field aliases within them, everything should
still be ok. It is alright if other use cases are added here if they fit within the `keyword` family as described here:
https://www.elastic.co/guide/en/elasticsearch/reference/7.12/keyword.html

View file

@ -0,0 +1,47 @@
{
"type": "doc",
"value": {
"id": "1",
"index": "const_keyword",
"source": {
"@timestamp": "2020-10-27T05:00:53.000Z"
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "2",
"index": "const_keyword",
"source": {
"@timestamp": "2020-10-27T05:01:53.000Z"
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "3",
"index": "const_keyword",
"source": {
"@timestamp": "2020-10-27T05:02:53.000Z"
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "4",
"index": "const_keyword",
"source": {
"@timestamp": "2020-10-27T05:03:53.000Z"
},
"type": "_doc"
}
}

View file

@ -0,0 +1,48 @@
{
"type": "index",
"value": {
"index": "const_keyword",
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": {
"type": "date"
},
"data_stream": {
"properties": {
"dataset": {
"type": "constant_keyword",
"value": "dataset_name_1"
},
"module": {
"type": "constant_keyword",
"value": "module_name_1"
}
}
},
"event": {
"properties": {
"category": {
"type": "keyword"
},
"dataset": {
"type": "alias",
"path": "data_stream.dataset"
},
"module": {
"type": "alias",
"path": "data_stream.module"
}
}
}
}
},
"settings": {
"index": {
"refresh_interval": "1s",
"number_of_replicas": "1",
"number_of_shards": "1"
}
}
}
}

View file

@ -0,0 +1,63 @@
{
"type": "doc",
"value": {
"id": "1",
"index": "keyword",
"source": {
"@timestamp": "2020-10-28T05:00:53.000Z",
"event": {
"module": "module_name_1",
"dataset": "dataset_name_1"
}
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "2",
"index": "keyword",
"source": {
"@timestamp": "2020-10-28T05:01:53.000Z",
"event": {
"module": "module_name_1",
"dataset": "dataset_name_1"
}
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "3",
"index": "keyword",
"source": {
"@timestamp": "2020-10-28T05:02:53.000Z",
"event": {
"module": "module_name_1",
"dataset": "dataset_name_1"
}
},
"type": "_doc"
}
}
{
"type": "doc",
"value": {
"id": "4",
"index": "keyword",
"source": {
"@timestamp": "2020-10-28T05:03:53.000Z",
"event": {
"module": "module_name_1",
"dataset": "dataset_name_1"
}
},
"type": "_doc"
}
}

View file

@ -0,0 +1,34 @@
{
"type": "index",
"value": {
"index": "keyword",
"mappings": {
"dynamic": "strict",
"properties": {
"@timestamp": {
"type": "date"
},
"event": {
"properties": {
"category": {
"type": "keyword"
},
"module": {
"type": "keyword"
},
"dataset": {
"type": "keyword"
}
}
}
}
},
"settings": {
"index": {
"refresh_interval": "1s",
"number_of_replicas": "1",
"number_of_shards": "1"
}
}
}
}