Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SIEM] Tests for search_after and bulk index #50129

Merged
merged 11 commits into from
Nov 15, 2019

Conversation

dhurley14
Copy link
Contributor

@dhurley14 dhurley14 commented Nov 11, 2019

Summary

Unit tests for reindexing mechanism for detection engine. This covers the search_after and bulk index methods used for moving events that match detection rules into the signals index. Also adds the maxSignals / maxDocs properties when using search_after to limit number of events queried against rules.

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
- [ ] Any text added follows EUI's writing guidelines, uses sentence case text and includes i18n support
- [ ] Documentation was added for features that require explanation or tutorials

For maintainers

@dhurley14 dhurley14 force-pushed the search-after-tests branch 2 times, most recently from ac05ba8 to de3e718 Compare November 11, 2019 04:31
@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@elasticmachine
Copy link
Contributor

Pinging @elastic/siem (Team:SIEM)

@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💔 Build Failed

@@ -96,7 +98,7 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTyp
name,
timeDetected: new Date().toISOString(),
filter: esFilter,
maxDocs: maxSignals,
maxDocs: typeof maxSignals === 'number' ? maxSignals : 1000,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want this and not:

maxSignals != null ? maxSignals : 1000

Something seems a bit off though. Looking through a lot of this...

In the definition of:
https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts#L35

I see that it does have a default and this number is different than that one above.

maxSignals: schema.number({ defaultValue: 100 })

Since the alert params defines a default is it possible to still put it into a null/undefined state?

I see below this change:

maxSignals: number | undefined;

But I'm curious if we are seeing maxSignals capable of being set to null/undefined?

Unit tests have test cases covering the cases
https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.test.ts#L347

Using the script post_signal.sh it posts the signal from ./signals/root_or_admin_1.json which has this content and not a maxSignals defined which is a good e2e ad hoc test:

{
  "id": "rule-1",
  "description": "Detecting root and admin users",
  "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
  "interval": "5m",
  "name": "Detect Root/Admin Users",
  "severity": "high",
  "type": "query",
  "from": "now-6m",
  "to": "now",
  "query": "user.name: root or user.name: admin",
  "language": "kuery",
  "references": ["http://www.example.com", "https://ww.example.com"]
}

Then on a read back: ./get_signal.sh rule-1

I see it coming back ok:

{
  "id": "03c687ff-98dd-4c29-ab10-a6370cf86cf2",
  "name": "Detect Root/Admin Users",
  "alertTypeId": "siem.signals",
  "alertTypeParams": {
    "description": "Detecting root and admin users",
    "id": "rule-1",
    "index": [
      "auditbeat-*",
      "filebeat-*",
      "packetbeat-*",
      "winlogbeat-*"
    ],
    "from": "now-6m",
    "filter": null,
    "query": "user.name: root or user.name: admin",
    "language": "kuery",
    "savedId": null,
    "filters": null,
    "maxSignals": 100,
    "severity": "high",
    "to": "now",
    "type": "query",
    "references": [
      "http://www.example.com",
      "https://ww.example.com"
    ]
  },
  "interval": "5m",
  "enabled": true,
  "actions": [
    {
      "group": "default",
      "params": {
        "message": "SIEM Alert Fired",
        "level": "info"
      },
      "id": "23d5f855-e9f1-443a-91f5-2bf261f3d69c"
    }
  ],
  "throttle": null,
  "createdBy": "elastic",
  "updatedBy": "elastic",
  "apiKeyOwner": "elastic",
  "muteAll": false,
  "mutedInstanceIds": [],
  "scheduledTaskId": "eacd87d0-0567-11ea-8933-b7a2b6b3b4ee"
}

On an update everything looks ok as well which also does not set the maxSignal:

./update_signal.sh
./get_signal.sh rule-1
{
  "id": "03c687ff-98dd-4c29-ab10-a6370cf86cf2",
  "name": "A different name",
  "alertTypeId": "siem.signals",
  "alertTypeParams": {
    "description": "Changed Description of only detecting root user",
    "id": "rule-1",
    "index": [
      "auditbeat-*"
    ],
    "from": "now-6m",
    "filter": null,
    "query": "user.name: root",
    "language": "kuery",
    "savedId": null,
    "filters": null,
    "maxSignals": 100,
    "severity": "high",
    "to": "now-5m",
    "type": "query",
    "references": [
      "https://update1.example.com",
      "https://update2.example.com"
    ]
  },
  "interval": "50m",
  "enabled": false,
  "actions": [
    {
      "params": {
        "level": "info",
        "message": "SIEM Alert Fired"
      },
      "group": "default",
      "id": "23d5f855-e9f1-443a-91f5-2bf261f3d69c"
    }
  ],
  "throttle": null,
  "createdBy": "elastic",
  "updatedBy": "elastic",
  "apiKeyOwner": "elastic",
  "muteAll": false,
  "mutedInstanceIds": [],
  "scheduledTaskId": null
}

Then I created this payload in /tmp/root_or_admin_1.json where I tried to update it to null:

{
  "id": "rule-1",
  "description": "Detecting root and admin users",
  "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"],
  "interval": "5m",
  "name": "Detect Root/Admin Users",
  "severity": "high",
  "type": "query",
  "from": "now-6m",
  "max_signals": null,
  "to": "now",
  "query": "user.name: root or user.name: admin",
  "language": "kuery",
  "references": ["http://www.example.com", "https://ww.example.com"]
}

And when running:

./update_signal.sh /tmp/root_or_admin_1.json

I get back this validation error:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "child \"max_signals\" fails because [\"max_signals\" must be a number]",
  "validation": {
    "source": "payload",
    "keys": [
      "max_signals"
    ]
  }
}

Also with a -1 it returns this:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "child \"max_signals\" fails because [\"max_signals\" must be greater than 0]",
  "validation": {
    "source": "payload",
    "keys": [
      "max_signals"
    ]
  }
}

But a regular number in the payload such as: 100000 looks to do an update correctly:

{
  "id": "03c687ff-98dd-4c29-ab10-a6370cf86cf2",
  "name": "Detect Root/Admin Users",
  "alertTypeId": "siem.signals",
  "alertTypeParams": {
    "description": "Detecting root and admin users",
    "filter": null,
    "from": "now-6m",
    "query": "user.name: root or user.name: admin",
    "language": "kuery",
    "savedId": null,
    "filters": null,
    "index": [
      "auditbeat-*",
      "filebeat-*",
      "packetbeat-*",
      "winlogbeat-*"
    ],
    "maxSignals": 100000,
    "severity": "high",
    "to": "now",
    "type": "query",
    "references": [
      "http://www.example.com",
      "https://ww.example.com"
    ],
    "id": "rule-1"
  },
  "interval": "5m",
  "enabled": false,
  "actions": [
    {
      "params": {
        "level": "info",
        "message": "SIEM Alert Fired"
      },
      "group": "default",
      "id": "23d5f855-e9f1-443a-91f5-2bf261f3d69c"
    }
  ],
  "throttle": null,
  "createdBy": "elastic",
  "updatedBy": "elastic",
  "apiKeyOwner": "elastic",
  "muteAll": false,
  "mutedInstanceIds": [],
  "scheduledTaskId": null
}

Although the echo'ed return (my fault still) is using camelCase ... I still to need to transform eventually on the way back out. I'm just being expedient by relying on saved objects.

However, if you see this set somewhere to undefined or null let me know? I think if you have settled on your new number above of 1000 I would set that in the schema and you can remove the above code and anywhere where maxSignals is null/undefined as that shouldn't be possible as long as we keep our validation on the way in accepting defaults.

@@ -31,7 +31,7 @@ export interface SignalAlertParams {
interval: string;
id: string;
language: string | undefined;
maxSignals: number;
maxSignals: number | undefined;
Copy link
Contributor

@FrankHassanabad FrankHassanabad Nov 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments above about if this type should change to be an undefined here. The source of "truth" for deriving TypeScript is from the alert schema as shown above which allows defaults:
https://github.com/elastic/kibana/pull/50129/files#r345313497

const bulkBody = sampleDocSearchResultsNoSortId.hits.hits.flatMap(doc => [
{
index: {
_index: process.env.SIGNALS_INDEX || '.siem-signals-10-01-2019',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'.siem-signals-10-01-2019'

Since we are going to be changing this with an upcoming PR as we finalize the index, how about we make this a const right in here:

x-pack/legacy/plugins/siem/common/constants.ts

Something in that file like this:

const DEFAULT_SIGNALS_INDEX = .siem-signals;

We should just drop the date time of -10-01-2019 in the name and then update everywhere we see this string to use that constant? It would make it easier to find all the spots and change them out when we change out the signals index way.

Looking closer though...

An additional thing we could do (as an aside) ... Just in case if we get the requirement to make the signals index output user driven/configurable ... Would be to make our functions which use the signals index "pure" and move our "impure" reading of the environment variable up more closer to the icky side effecting part of the "handler" for routes.

That's also better for your tests as you can inject what you want, and technically functions which read something global such as env are not pure ...

So, for example,

export const singleBulkIndex = async (
  sr: SignalSearchResponse,
  params: SignalAlertParams,
  service: AlertServices,
  logger: Logger
): Promise<boolean> => {

Would just become something like:

export const singleBulkIndex = async (
  sr: SignalSearchResponse,
  params: SignalAlertParams,
  service: AlertServices,
  index: string,
  logger: Logger
): Promise<boolean> => {

And then all calling code above this will keep pushing down index until you get right to the handler. If you feel the arguments are too long are hard to read, then you can make them into an options object like we have in other spots.

This would mirror the buildEventsReIndex({ API where it injects the index into its self. Lifting state and side effects up more towards the top of the application typically leads to better testable pure functions and an overall better and easier time to adapt code to changing requirements. Also the access of the environment would be grouped together in just one or two files (hopefully).

Let me know what you think. This part is optional and can be a follow on PR, but seeing these tests, this might be a great time to just do it and get it out of the way.

sortIds = searchAfterResult.hits.hits[0].sort;
if (sortIds == null) {
logger.error('sortIds was empty search when running a signal rule');
return false;
logger.error('sortIds was empty on search');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you still want this as an error even though you are returning true below to indicate a non-error?

let sortIds = someResult.hits.hits[0].sort;
if (sortIds == null && totalHits > 0) {
logger.error(`sortIds was empty on first search when encountering ${totalHits}`);
logger.warn('sortIds was empty on first search but expected more');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want this to be a warn even though below you are returning false to indicate an error?

@@ -74,7 +76,8 @@ export const buildEventsSearchQuery = ({
],
},
},
track_total_hits: true,
// if we have maxDocs, don't utilize track total hits.
track_total_hits: maxDocs != null ? false : true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments below but I think that you are always going to have a maxDocs?
https://github.com/elastic/kibana/pull/50129/files#r345313497

I think its good that we always carry a maximum value and a default even if the user tries to get around it they will have to be very intentional in increasing the number to a very high number.

Let me know otherwise. We can always increase it to a very high default, but carrying a sensible lower default feels more like the same as the other API's from Elastic we work with.

}),
fatal: jest.fn(),
log: jest.fn(),
};
Copy link
Contributor

@FrankHassanabad FrankHassanabad Nov 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Searching through the code base I think this compact form is most of what the other code writers in other sections of Kibana use for mock logger:

const mockLogger: Logger = {
  log: jest.fn(),
  trace: jest.fn(),
  debug: jest.fn(),
  info: jest.fn(),
  warn: jest.fn(),
  error: jest.fn(),
  fatal: jest.fn(),
};

Testing it and it seems to work well here.

But if you need the echo'ing out of stuff that it is fine as well as is. Just pointing it out.

});
describe('buildBulkBody', () => {
test('if bulk body builds well-defined body', () => {
const sampleParams: SignalAlertParams = sampleSignalAlertParams(undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, the type is already inferred so you only need:

const sampleParams = sampleSignalAlertParams(undefined);

mockLogger
);
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
expect(mockLogger.warn).toHaveBeenCalledTimes(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I would be careful about tests that are checking how many times a mock logger debug or warn statement is being called. That gets very specific and a maintainer will be updating these numbers as they either add or remove debug and warn statements from our loggers?

If it's 3 vs 2 times vs 5, etc... I don't know what extra value that provides for the test.

I understand checking if something is called for part of branching logic, but writing a lot of tests around how many times a particular logger debug or warn is being called is going to add more overhead to maintaining and not really provide much value IMHO.

I wouldn't have expects for these

);
expect(mockLogger.debug).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledTimes(0);
expect(successfulSingleBulkIndex).toEqual(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having the return value being checked is great though 👍! I like the tests around the return types.

expect(mockLogger.debug).toHaveBeenCalledTimes(2);
expect(mockLogger.warn).toHaveBeenCalledTimes(0);
expect(mockLogger.error).toHaveBeenCalledTimes(1);
expect(successfulSingleBulkIndex).toEqual(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general for most of our tests we want to have one or maybe at most two or three expects per test. These are pushing the boundary a bit, and you don't have to split these all out, but if you look around the code base most of the examples of unit tests within our plugin, core, and other plugins usually follow the motto of one expect per test section around ~95% of the time.

Exception to the rule would be table test areas where there is a big array and then a forEach ontop of that array to test all the values. That is a bit different paradigm but within that we are hopeful that as the table test executes and if it fails it will be one failure per test block still (just wrapped in an array).

);
expect(searchAfterResult).toBeEmpty();
} catch (exc) {
expect(exc.message).toEqual('Attempted to search after with empty sort id');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing try catch blocks like this, you have a general problem which is that expect statement will only execute and fail the test if your function does a throw and then in the catch it sees a different value.

However, if your function of singleSearchAfter never does a throw, you never reach the catch block and never fail the test. Instead it continues and reports the test as still passing. You have what looks like an extra expect to catch that case well but there is one other way.

If you want to fail your test if you do not throw then what you want to use is rejects.toThrow. Examples are in our code but this one would be:

      await expect(
        singleSearchAfter(searchAfterSortId, sampleParams, mockService, mockLogger)
      ).rejects.toThrow('Attempted to search after with empty sort id');

Then if that does not do a throw it will fail the test and you don't need to write extra expect statements for when it does not fail the test.

callCluster: async (action: string, params: SearchParams) => {
if (action === 'bulk') {
expect(action).toEqual('bulk');
expect(params.index).toEqual('.siem-signals-10-01-2019');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks for SIEM signals do fail if I have a custom SIEM dev signals configured via my environment. We will be fixing/changing/removing the env variables but for now I would add a simple function for these tests which does:

  • moves the string to our common/constants
  • Simple function in your tests which gets either the configured env. or the default SIEM index.
const { DEFAULT_SIEM_INDEX } = '../../constants'
...
const getTestIndex = () => process.env.SIGNALS_INDEX || DEFAULT_SIEM_INDEX
...
expect(params.index).toEqual(getTestIndex());

],
};
}
expect(action).toEqual('search');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per my earlier comment about trying to have only 1, maybe 2 expect statement at most per test for maintainers and developers to easily understand what is going on and reading code...

This is a bit of an example of why that is needed because starting on lines 494 thorough 497, these expect statements are never reached. Reason being is that on line 484, you have an early return statement.

This part of the test is confusing to me because I see one expect statement which says:

expect(action).toEqual('bulk');

But then on 494 I see a contradiction of:

expect(action).toEqual('search');

Since you have early return statements for this and if we followed the paradigm of the least amount of expect statements per test this would actually become this code:

    test('if one successful iteration of searchAfterAndBulkIndex', async () => {
      const searchAfterSortId = '1234567891111';
      const sampleParams = sampleSignalAlertParams(undefined);
      const savedObjectsClient = savedObjectsClientMock.create();
      buildEventsSearchQuery({
        index: sampleParams.index,
        from: sampleParams.from,
        to: sampleParams.to,
        filter: sampleParams.filter,
        size: sampleParams.size ? sampleParams.size : 1,
        searchAfterSortId,
        maxDocs: undefined,
      });
      const mockService: AlertServices = {
        callCluster: async (action: string, params: SearchParams) => {
          expect(action).toEqual('bulk');
          expect(params.index).toEqual('.siem-signals-10-01-2019');
          return {
            took: 100,
            errors: false,
            items: [
              {
                fakeItemValue: 'fakeItemKey',
              },
            ],
          };
        },
        alertInstanceFactory: jest.fn(),
        savedObjectsClient,
      };
      const result = await searchAfterAndBulkIndex(
        sampleDocSearchResultsWithSortId,
        sampleParams,
        mockService,
        mockLogger
      );
      expect(result).toEqual(true);
    });

You do want to avoid dead branching logic within tests as much as possible such as if statements or larger harnesses so we can be sure that each expect statement is reached and each expect statement does indeed do what we intend it to do which is prove a particular value's truthfulness.

],
};
}
expect(action).toEqual('search');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above, starting on line 538-540, these expect statements look to never reached. I would remove them and the if check if that is the case.


const mockService: AlertServices = {
callCluster: jest.fn(async (action: string, params: SearchParams) => {
if (action === 'bulk') {
Copy link
Contributor

@FrankHassanabad FrankHassanabad Nov 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some of the tests such as this one where the callCluster is called repeatedly with values

I would rely on using if possible just pure jest to ask jest about each time the function was called and with what parameters. Likewise Jest has ways of mocking heterogenous return values based on number of times something is called rather than mixing data driven values (which might make code changes down the road harder):
https://jestjs.io/docs/en/mock-function-api.html#mockfnmockimplementationoncefn

I see what some of these are doing, but the harder part is trying to keep the mocking portion separated from the expect values and avoid mixing them together to where you have part mock and part test pushed together. Rather a good refactor (doesn't have to be with this PR since there is so much here already) is to try and keep them separated from each other and of course keep the expects down to a concentrated 1 or 2 per test.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💔 Build Failed

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Copy link
Contributor

@FrankHassanabad FrankHassanabad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! Thanks for all the fixes and cleanups, this is going to help my next PR a LOT to ensure I didn't break anything.

@dhurley14 dhurley14 merged commit 040c309 into elastic:master Nov 15, 2019
dhurley14 added a commit to dhurley14/kibana that referenced this pull request Nov 15, 2019
* tests for detection engine get/put utils

* increases unit test code statement coverage to 100% for search_after / bulk index reindexer

* removes mockLogger declaration from individual test cases - clears mock counts before each test case runs so as to not accumulate method calls after each test case

* resets default paging size to 1000 - typo from when I was working through my tests

* updates tests after rebase with master

* fixes type check after fixing test from rebase with master

* removes undefined from maxSignals in type definition, updates tests with pure jest function implementations of logger and services - modifying only the return values or creating a mock implementation when necessary, removed some overlapping test cases

* fixes type issue

* replaces mock implementation with mock return value for unit test

* removes mock logger expected counts, just check if error logs are called, don't care about debug / warn etc.

* fixes more type checks after rebase with master
FrankHassanabad pushed a commit that referenced this pull request Nov 16, 2019
* tests for detection engine get/put utils

* increases unit test code statement coverage to 100% for search_after / bulk index reindexer

* removes mockLogger declaration from individual test cases - clears mock counts before each test case runs so as to not accumulate method calls after each test case

* resets default paging size to 1000 - typo from when I was working through my tests

* updates tests after rebase with master

* fixes type check after fixing test from rebase with master

* removes undefined from maxSignals in type definition, updates tests with pure jest function implementations of logger and services - modifying only the return values or creating a mock implementation when necessary, removed some overlapping test cases

* fixes type issue

* replaces mock implementation with mock return value for unit test

* removes mock logger expected counts, just check if error logs are called, don't care about debug / warn etc.

* fixes more type checks after rebase with master
jloleysens added a commit to jloleysens/kibana that referenced this pull request Nov 18, 2019
…her [skip ci]

* upstream/master: (54 commits)
  allows plugins to define validation schema for "enabled" flag (elastic#50286)
  Add retry to find.existsByDisplayedByCssSelector (elastic#48734)
  [i18n] integrate latest translations (elastic#50864)
  ui/resize_checker 👉 src/plugins/kibana_utils (elastic#44750)
  Fix @reach/router types (elastic#50863)
  [ML] Adding ML node warning to overview and analytics pages (elastic#50766)
  Bump storybook dependencies (elastic#50752)
  [APM Replace usage of idx with optional chaining (elastic#50849)
  [SIEM] Fix eslint errors (elastic#49713)
  Improve "Browser client is out of date" error message (elastic#50296)
  [SIEM][Detection Engine] REST API improvements and changes from UI/UX feedback (elastic#50797)
  Move @kbn/es-query into data plugin - es-query folder (elastic#50182)
  Index Management new platform migration (elastic#49359)
  Increase retry for cloud snapshot to finish (elastic#50781)
  Removing EuiCode from inside EuiPanel (elastic#50683)
  [SIEM] Tests for search_after and bulk index (elastic#50129)
  Make babel understand TypeScript 3.7 syntax (elastic#50772)
  Fixing mocha tests and broken password change status codes (elastic#50704)
  [Canvas] Use compressed forms in sidebar (elastic#49419)
  Add labels to shell scripts in Jenkins (elastic#49657)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants