Skip to content

Commit

Permalink
[Security Solution] Implement coverage overview dashboard API (#160480)
Browse files Browse the repository at this point in the history
**Addresses:** #158238

## Summary

This PR adds Coverage Overview API endpoint necessary to implement Coverage Overview Dashboard.

## Details

The Coverage Overview API implementation is represented by one HTTP POST internal route `/internal/detection_engine/rules/_coverage_overview` hidden by a feature flag `detectionsCoverageOverview`. It returns response in the format defined in #159993.

Implementation is done in a quite simple way. It basically just fetches all the rules in chunks and adds them to appropriate MITRE ATT&CK category buckets depending on the assigned categories. The chunk size has been chosen to be `10000` as it's the default limit.

At the current stage the API doesn't handle available rules which means it doesn't return available rules in the response.

Sample response containing two rules looks like

```json
{
  "coverage": {
    "TA001": ["e2c9ee90-12d6-11ee-a0ab-c95a1fc4921d"],
    "T001": ["e2c9ee90-12d6-11ee-a0ab-c95a1fc4921d"],
    "T001.001": ["e2c9ee90-12d6-11ee-a0ab-c95a1fc4921d"],
    "TA002": ["e2f459f0-12d6-11ee-a0ab-c95a1fc4921d"],
    "T002": ["e2f459f0-12d6-11ee-a0ab-c95a1fc4921d"],
    "T002.002": ["e2f459f0-12d6-11ee-a0ab-c95a1fc4921d"],
  },
  "unmapped_rule_ids": [],
  "rules_data": {
    "e2c9ee90-12d6-11ee-a0ab-c95a1fc4921d": { "name": "Some rule", "activity": "disabled" },
    "e2f459f0-12d6-11ee-a0ab-c95a1fc4921d": { "name": "Another rule", "activity": "enabled" },
  },
}
```

### How to access the endpoint?

Make sure a feature `detectionsCoverageOverview` flag is set in `config/kibana.dev.yml` like

```yaml
xpack.securitySolution.enableExperimental:
  - detectionsCoverageOverview
```

Then access the API via an HTTP client for example `curl`

- an empty filter  
```sh
curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -d '{}' http://localhost:5601/kbn/internal/detection_engine/rules/_coverage_overview
```

- filter by rule name
```sh
curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -d '{"filter":{"search_term": "rule name"}}' http://localhost:5601/kbn/internal/detection_engine/rules/_coverage_overview
```

- filter by enabled rules
```sh
curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -d '{"filter":{"activity": ["enabled"]}}' http://localhost:5601/kbn/internal/detection_engine/rules/_coverage_overview
```

- filter by prebuilt rules
```sh
curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -d '{"filter":{"source": ["prebuilt"]}}' http://localhost:5601/kbn/internal/detection_engine/rules/_coverage_overview
```

## Known problems

- <del>Filtering by a tactic name doesn't guarantee the other tactics, techniques and sub-techniques will be filtered out. As one rule may be assigned to more than one tactic, technique and sub-technique filtering such rules by one tactic will lead to only rules assigned to the desired tactic be processed. But the result will include all the tactics, techniques and sub-techniques assigned to that rules.</del>

UPD: leave as is for now

- <del>Some of the implementation details are similar to `find_rules` endpoint. The difference is that `find_rules` accepts `query` parameter which is a KQL query while `coverage_overview` accepts filter fields and builds up a KQL query under the hood. Passing a prepared KQL query to `coverage_overview` looks too permissive and can lead to undesired filtering results. Some of KQL query building code is common and can be reused between FE and BE.</del>

UPD: Solved

- <del>One may ask why using an HTTP POST request instead of HTTP GET. In fact HTTP POST is needed only for convenience to send a JSON request query in the request body similar to GraphQL approach but it looks rather an overkill. One of the main reasons why HTTP POST is used is the limitation of `io-ts` schemas used to request query validation. It's handled by `buildRouteValidation()` which doesn't parse input parameters.  For example there is a request with a query string `/internal/detection_engine/rules/_coverage_overview?filter={"search_term": "rule 1"}`, it's handled and the following object gets passed to `buildRouteValidation()`</del>

```ts
{
  "filter": '{"search_term": "rule 1"}'
}
```

<del>as you may notice `'{"search_term": "rule 1"}'` is a string so the `io-ts` schema validation fails while the request looks correct. In contrast a similar `@kbn/config-schema` schema used instead for the request query validation handles it correctly. As the reference it works [here](https://github.com/elastic/kibana/blob/main/x-pack/plugins/alerting/server/routes/find_rules.ts#L100C16-L100C27) for `internal/alerting/rules/_find` endpoint, `fields` query parameter can be a JSON array and validation handles it correctly.</del>

UPD: discussed with the team and decided that HTTP POST is more convenient for complex filters.

- During FTR tests implementation I've noticed the server fails if the second page (10000 - 20000 rules) is requested with an error
```
illegal_argument_exception: Result window is too large, from + size must be less than or equal to: [10000] but was [20000]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting.
```

There is a chance it can fail with the same error in prod.

UPD: The problem in reproducible in prod. To avoid a server crash the endpoint doesn't handle more than 10k rules. The problem will be addressed in #160698.

## Posible improvements

- [x] Move KQL utility functions into common folder to be shared between FE and BE (done)
- Implement stricter filtering to return only searched tactic, technique and sub-technique (leave as is for now)
- Use HTTP GET instead of HTTP POST (discussed with the team and decided that HTTP POST is more convenient for complex filters)
- Make sure pages above 10000 rules are handled properly (will be addresses in #160698)

### Checklist

- [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
- [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 scenario
  • Loading branch information
maximpn authored Jun 29, 2023
1 parent 0a1b516 commit 4ee1874
Show file tree
Hide file tree
Showing 22 changed files with 845 additions and 144 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const CoverageOverviewFilter = t.partial({
});

export type CoverageOverviewRequestBody = t.TypeOf<typeof CoverageOverviewRequestBody>;
export const CoverageOverviewRequestBody = t.partial({
filter: CoverageOverviewFilter,
});
export const CoverageOverviewRequestBody = t.exact(
t.partial({
filter: CoverageOverviewFilter,
})
);
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
import { INTERNAL_DETECTION_ENGINE_URL } from '../../../constants';

export const RULE_MANAGEMENT_FILTERS_URL = `${INTERNAL_DETECTION_ENGINE_URL}/rules/_rule_management_filters`;
export const RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL = `${INTERNAL_DETECTION_ENGINE_URL}/rules/_coverage_overview`;
24 changes: 24 additions & 0 deletions x-pack/plugins/security_solution/common/rule_fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.
*/

export const RULE_NAME_FIELD = 'alert.attributes.name';

export const RULE_PARAMS_FIELDS = {
INDEX: 'alert.attributes.params.index',
TACTIC_ID: 'alert.attributes.params.threat.tactic.id',
TACTIC_NAME: 'alert.attributes.params.threat.tactic.name',
TECHNIQUE_ID: 'alert.attributes.params.threat.technique.id',
TECHNIQUE_NAME: 'alert.attributes.params.threat.technique.name',
SUBTECHNIQUE_ID: 'alert.attributes.params.threat.technique.subtechnique.id',
SUBTECHNIQUE_NAME: 'alert.attributes.params.threat.technique.subtechnique.name',
} as const;

export const ENABLED_FIELD = 'alert.attributes.enabled';
export const TAGS_FIELD = 'alert.attributes.tags';
export const PARAMS_TYPE_FIELD = 'alert.attributes.params.type';
export const PARAMS_IMMUTABLE_FIELD = 'alert.attributes.params.immutable';
export const LAST_RUN_OUTCOME_FIELD = 'alert.attributes.lastRun.outcome';
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
* 2.0.
*/

import type { FilterOptions } from './types';
import { convertRulesFilterToKQL } from './utils';
import { convertRulesFilterToKQL } from './kql';

describe('convertRulesFilterToKQL', () => {
const filterOptions: FilterOptions = {
const filterOptions = {
filter: '',
showCustomRules: false,
showElasticRules: false,
Expand All @@ -34,7 +33,7 @@ describe('convertRulesFilterToKQL', () => {
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)")'
'(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\\)")'
);
});

Expand Down Expand Up @@ -75,7 +74,7 @@ describe('convertRulesFilterToKQL', () => {
});

expect(kql).toBe(
`alert.attributes.params.immutable: true AND alert.attributes.tags:("tag1" AND "tag2") AND (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")`
`(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\")`
);
});

Expand Down
115 changes: 115 additions & 0 deletions x-pack/plugins/security_solution/common/utils/kql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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 { escapeKuery } from '@kbn/es-query';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { RuleExecutionStatus } from '../detection_engine/rule_monitoring';
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<RulesFilterOptions>): 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}: "${escapeKuery(searchTerm)}"`).join(' OR ');
}

export function convertRuleTagsToKQL(tags: string[]): string {
return `${TAGS_FIELD}:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})`;
}

export function convertRuleTypesToKQL(ruleTypes: Type[]): string {
return `${PARAMS_TYPE_FIELD}: (${ruleTypes
.map((ruleType) => `"${escapeKuery(ruleType)}"`)
.join(' OR ')})`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,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',
Expand Down Expand Up @@ -395,7 +395,7 @@ describe('Detections Rules API', () => {
method: 'GET',
query: {
filter:
'alert.attributes.tags:("hello" AND "world") AND (alert.attributes.name: "ruleName" OR alert.attributes.params.index: "ruleName" OR alert.attributes.params.threat.tactic.id: "ruleName" OR alert.attributes.params.threat.tactic.name: "ruleName" OR alert.attributes.params.threat.technique.id: "ruleName" OR alert.attributes.params.threat.technique.name: "ruleName" OR alert.attributes.params.threat.technique.subtechnique.id: "ruleName" OR alert.attributes.params.threat.technique.subtechnique.name: "ruleName")',
'(alert.attributes.name: "ruleName" OR alert.attributes.params.index: "ruleName" OR alert.attributes.params.threat.tactic.id: "ruleName" OR alert.attributes.params.threat.tactic.name: "ruleName" OR alert.attributes.params.threat.technique.id: "ruleName" OR alert.attributes.params.threat.technique.name: "ruleName" OR alert.attributes.params.threat.technique.subtechnique.id: "ruleName" OR alert.attributes.params.threat.technique.subtechnique.name: "ruleName") AND alert.attributes.tags:("hello" AND "world")',
page: 1,
per_page: 20,
sort_field: 'enabled',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +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 type { UpgradeSpecificRulesRequest } from '../../../../common/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_request_schema';
import type { PerformRuleUpgradeResponseBody } from '../../../../common/detection_engine/prebuilt_rules/api/perform_rule_upgrade/perform_rule_upgrade_response_schema';
import type { InstallSpecificRulesRequest } from '../../../../common/detection_engine/prebuilt_rules/api/perform_rule_installation/perform_rule_installation_request_schema';
Expand Down Expand Up @@ -73,7 +74,6 @@ import type {
RulesSnoozeSettingsMap,
UpdateRulesProps,
} from '../logic/types';
import { convertRulesFilterToKQL } from '../logic/utils';
import type { ReviewRuleUpgradeResponseBody } from '../../../../common/detection_engine/prebuilt_rules/api/review_rule_upgrade/response_schema';
import type { ReviewRuleInstallationResponseBody } from '../../../../common/detection_engine/prebuilt_rules/api/review_rule_installation/response_schema';

Expand Down Expand Up @@ -169,14 +169,14 @@ export const fetchRules = async ({
},
signal,
}: FetchRulesProps): Promise<FetchRulesResponse> => {
const filterString = convertRulesFilterToKQL(filterOptions);
const kql = convertRulesFilterToKQL(filterOptions);

const query = {
page: pagination.page,
per_page: pagination.perPage,
sort_field: sortingOptions.field,
sort_order: sortingOptions.order,
...(filterString !== '' ? { filter: filterString } : {}),
...(kql !== '' ? { filter: kql } : {}),
};

return KibanaServices.get().http.fetch<FetchRulesResponse>(DETECTION_ENGINE_RULES_URL_FIND, {
Expand Down

This file was deleted.

Loading

0 comments on commit 4ee1874

Please sign in to comment.