[SECURITY SOLUTION] Get case ids from alert ids (#98702)

* wip to get caseIds with alertId

* make work the API with the latest aggs from SO :)

* review

* change logic to re-do aggregation if we think that we are missing data

* Integration tests

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
This commit is contained in:
Xavier Mouligneau 2021-05-05 01:55:13 -04:00 committed by GitHub
parent 669be33792
commit 5de608cad8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 360 additions and 20 deletions

View file

@ -14,6 +14,7 @@ import { schema as s, ObjectType } from '@kbn/config-schema';
* Currently supported:
* - filter
* - histogram
* - nested
* - terms
*
* Not implemented:
@ -32,7 +33,6 @@ import { schema as s, ObjectType } from '@kbn/config-schema';
* - ip_range
* - missing
* - multi_terms
* - nested
* - parent
* - range
* - rare_terms
@ -42,6 +42,7 @@ import { schema as s, ObjectType } from '@kbn/config-schema';
* - significant_text
* - variable_width_histogram
*/
export const bucketAggsSchemas: Record<string, ObjectType> = {
filter: s.object({
term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])),
@ -71,6 +72,9 @@ export const bucketAggsSchemas: Record<string, ObjectType> = {
})
),
}),
nested: s.object({
path: s.string(),
}),
terms: s.object({
field: s.maybe(s.string()),
collect_mode: s.maybe(s.string()),

View file

@ -16,6 +16,12 @@ const mockMappings = {
updated_at: {
type: 'date',
},
references: {
type: 'nested',
properties: {
id: 'keyword',
},
},
foo: {
properties: {
title: {
@ -182,6 +188,40 @@ describe('validateAndConvertAggregations', () => {
});
});
it('validates a nested root aggregations', () => {
expect(
validateAndConvertAggregations(
['alert'],
{
aggName: {
nested: {
path: 'alert.references',
},
aggregations: {
aggName2: {
terms: { field: 'alert.references.id' },
},
},
},
},
mockMappings
)
).toEqual({
aggName: {
nested: {
path: 'references',
},
aggregations: {
aggName2: {
terms: {
field: 'references.id',
},
},
},
},
});
});
it('rewrites type attributes when valid', () => {
const aggregations: AggsMap = {
average: {
@ -428,4 +468,50 @@ describe('validateAndConvertAggregations', () => {
`"[someAgg.aggs.nested.max.script]: definition for this key is missing"`
);
});
it('throws an error when trying to access a property via {type}.{type}.attributes.{attr}', () => {
expect(() => {
validateAndConvertAggregations(
['alert'],
{
aggName: {
cardinality: {
field: 'alert.alert.attributes.actions.group',
},
aggs: {
aggName: {
max: { field: 'alert.alert.attributes.actions.group' },
},
},
},
},
mockMappings
);
}).toThrowErrorMatchingInlineSnapshot(
'"[aggName.cardinality.field] Invalid attribute path: alert.alert.attributes.actions.group"'
);
});
it('throws an error when trying to access a property via {type}.{type}.{attr}', () => {
expect(() => {
validateAndConvertAggregations(
['alert'],
{
aggName: {
cardinality: {
field: 'alert.alert.actions.group',
},
aggs: {
aggName: {
max: { field: 'alert.alert.actions.group' },
},
},
},
},
mockMappings
);
}).toThrowErrorMatchingInlineSnapshot(
'"[aggName.cardinality.field] Invalid attribute path: alert.alert.actions.group"'
);
});
});

View file

@ -56,10 +56,13 @@ const validateAggregations = (
aggregations: Record<string, estypes.AggregationContainer>,
context: ValidationContext
) => {
return Object.entries(aggregations).reduce((memo, [aggrName, aggrContainer]) => {
memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName));
return memo;
}, {} as Record<string, estypes.AggregationContainer>);
return Object.entries(aggregations).reduce<Record<string, estypes.AggregationContainer>>(
(memo, [aggrName, aggrContainer]) => {
memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName));
return memo;
},
{}
);
};
/**
@ -93,15 +96,18 @@ const validateAggregationContainer = (
container: estypes.AggregationContainer,
context: ValidationContext
) => {
return Object.entries(container).reduce((memo, [aggName, aggregation]) => {
if (aggregationKeys.includes(aggName)) {
return memo;
}
return {
...memo,
[aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)),
};
}, {} as estypes.AggregationContainer);
return Object.entries(container).reduce<estypes.AggregationContainer>(
(memo, [aggName, aggregation]) => {
if (aggregationKeys.includes(aggName)) {
return memo;
}
return {
...memo,
[aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)),
};
},
{}
);
};
const validateAggregationType = (
@ -143,7 +149,7 @@ const validateAggregationStructure = (
* },
* ```
*/
const attributeFields = ['field'];
const attributeFields = ['field', 'path'];
/**
* List of fields that have a Record<attribute path, value> as value
*

View file

@ -24,15 +24,17 @@ export const isRootLevelAttribute = (
allowedTypes: string[]
): boolean => {
const splits = attributePath.split('.');
if (splits.length !== 2) {
if (splits.length <= 1) {
return false;
}
const [type, fieldName] = splits;
if (allowedTypes.includes(fieldName)) {
const [type, firstPath, ...otherPaths] = splits;
if (allowedTypes.includes(firstPath)) {
return false;
}
return allowedTypes.includes(type) && fieldDefined(indexMapping, fieldName);
return (
allowedTypes.includes(type) && fieldDefined(indexMapping, [firstPath, ...otherPaths].join('.'))
);
};
/**
@ -45,7 +47,8 @@ export const isRootLevelAttribute = (
* ```
*/
export const rewriteRootLevelAttribute = (attributePath: string) => {
return attributePath.split('.')[1];
const [, ...attributes] = attributePath.split('.');
return attributes.join('.');
};
/**

View file

@ -9,6 +9,21 @@ import * as rt from 'io-ts';
import { UserRT } from '../user';
const BucketsAggs = rt.array(
rt.type({
key: rt.string,
})
);
export const GetCaseIdsByAlertIdAggsRt = rt.type({
references: rt.type({
doc_count: rt.number,
caseIds: rt.type({
buckets: BucketsAggs,
}),
}),
});
/**
* this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only
* be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and
@ -123,3 +138,4 @@ export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>;
export type CommentPatchAttributes = rt.TypeOf<typeof CommentPatchAttributesRt>;
export type CommentRequestUserType = rt.TypeOf<typeof ContextTypeUserRt>;
export type CommentRequestAlertType = rt.TypeOf<typeof AlertCommentRequestRt>;
export type GetCaseIdsByAlertIdAggs = rt.TypeOf<typeof GetCaseIdsByAlertIdAggsRt>;

View file

@ -31,6 +31,8 @@ export const CASE_STATUS_URL = `${CASES_URL}/status`;
export const CASE_TAGS_URL = `${CASES_URL}/tags`;
export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`;
export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}`;
/**
* Action routes
*/

View file

@ -0,0 +1,48 @@
/*
* 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 { schema } from '@kbn/config-schema';
import Boom from '@hapi/boom';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CASE_ALERTS_URL } from '../../../../../common/constants';
export function initGetCaseIdsByAlertIdApi({ caseService, router, logger }: RouteDeps) {
router.get(
{
path: CASE_ALERTS_URL,
validate: {
params: schema.object({
alert_id: schema.string(),
}),
},
},
async (context, request, response) => {
try {
const alertId = request.params.alert_id;
if (alertId == null || alertId === '') {
throw Boom.badRequest('The `alertId` is not valid');
}
const client = context.core.savedObjects.client;
const caseIds = await caseService.getCaseIdsByAlertId({
client,
alertId,
});
return response.ok({
body: caseIds,
});
} catch (error) {
logger.error(
`Failed to retrieve case ids for this alert id: ${request.params.alert_id}: ${error}`
);
return response.customError(wrapError(error));
}
}
);
}

View file

@ -38,6 +38,7 @@ import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases';
import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases';
import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases';
import { ENABLE_CASE_CONNECTOR } from '../../../common/constants';
import { initGetCaseIdsByAlertIdApi } from './cases/alerts/get_cases';
/**
* Default page number when interacting with the saved objects API.
@ -86,4 +87,6 @@ export function initCaseApi(deps: RouteDeps) {
initGetCasesStatusApi(deps);
// Tags
initGetTagsApi(deps);
// Alerts
initGetCaseIdsByAlertIdApi(deps);
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { AggregationContainer } from '@elastic/elasticsearch/api/types';
import {
KibanaRequest,
Logger,
@ -17,6 +18,7 @@ import {
SavedObjectsBulkResponse,
SavedObjectsFindResult,
} from 'kibana/server';
import { nodeBuilder } from '../../../../../src/plugins/data/common';
import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server';
import {
@ -34,6 +36,7 @@ import {
CaseResponse,
caseTypeField,
CasesFindRequest,
GetCaseIdsByAlertIdAggs,
} from '../../common';
import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common';
import { defaultPage, defaultPerPage } from '../routes/api';
@ -113,6 +116,10 @@ interface GetCommentArgs extends ClientArgs {
commentId: string;
}
interface GetCaseIdsByAlertIdArgs extends ClientArgs {
alertId: string;
}
interface PostCaseArgs extends ClientArgs {
attributes: ESCaseAttributes;
}
@ -220,6 +227,7 @@ export interface CaseServiceSetup {
getSubCases(args: GetSubCasesArgs): Promise<SavedObjectsBulkResponse<SubCaseAttributes>>;
getCases(args: GetCasesArgs): Promise<SavedObjectsBulkResponse<ESCaseAttributes>>;
getComment(args: GetCommentArgs): Promise<SavedObject<CommentAttributes>>;
getCaseIdsByAlertId(args: GetCaseIdsByAlertIdArgs): Promise<string[]>;
getTags(args: ClientArgs): Promise<string[]>;
getReporters(args: ClientArgs): Promise<User[]>;
getUser(args: GetUserArgs): Promise<AuthenticatedUser | User>;
@ -899,6 +907,56 @@ export class CaseService implements CaseServiceSetup {
}
}
private buildCaseIdsAggs = (size: number = 100): Record<string, AggregationContainer> => ({
references: {
nested: {
path: `${CASE_COMMENT_SAVED_OBJECT}.references`,
},
aggregations: {
caseIds: {
terms: {
field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`,
size,
},
},
},
},
});
public async getCaseIdsByAlertId({
client,
alertId,
}: GetCaseIdsByAlertIdArgs): Promise<string[]> {
try {
this.log.debug(`Attempting to GET all cases for alert id ${alertId}`);
let response = await client.find<CommentAttributes, GetCaseIdsByAlertIdAggs>({
type: CASE_COMMENT_SAVED_OBJECT,
fields: [],
page: 1,
perPage: 1,
sortField: defaultSortField,
aggs: this.buildCaseIdsAggs(),
filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId),
});
if (response.total > 100) {
response = await client.find<CommentAttributes, GetCaseIdsByAlertIdAggs>({
type: CASE_COMMENT_SAVED_OBJECT,
fields: [],
page: 1,
perPage: 1,
sortField: defaultSortField,
aggs: this.buildCaseIdsAggs(response.total),
filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId),
});
}
return response.aggregations?.references.caseIds.buckets.map((b) => b.key) ?? [];
} catch (error) {
this.log.error(`Error on GET all cases for alert id ${alertId}: ${error}`);
throw error;
}
}
/**
* Default behavior is to retrieve all comments that adhere to a given filter (if one is included).
* to override this pass in the either the page or perPage options.

View file

@ -31,6 +31,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({
getAllSubCaseComments: jest.fn(),
getCase: jest.fn(),
getCases: jest.fn(),
getCaseIdsByAlertId: jest.fn(),
getComment: jest.fn(),
getMostRecentSubCase: jest.fn(),
getSubCase: jest.fn(),

View file

@ -0,0 +1,112 @@
/*
* 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 { CASES_URL } from '../../../../../../plugins/cases/common/constants';
import { postCaseReq, postCommentAlertReq } from '../../../../common/lib/mock';
import { deleteAllCaseItems } from '../../../../common/lib/utils';
import { CaseResponse } from '../../../../../../plugins/cases/common';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('get_cases using alertID', () => {
const createCase = async () => {
const { body } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
return body;
};
const createComment = async (caseID: string) => {
await supertest
.post(`${CASES_URL}/${caseID}/comments`)
.set('kbn-xsrf', 'true')
.send(postCommentAlertReq)
.expect(200);
};
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should return all cases with the same alert ID attached to them', async () => {
const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]);
await Promise.all([
createComment(case1.id),
createComment(case2.id),
createComment(case3.id),
]);
const { body: caseIDsWithAlert } = await supertest
.get(`${CASES_URL}/alerts/test-id`)
.expect(200);
expect(caseIDsWithAlert.length).to.eql(3);
expect(caseIDsWithAlert).to.contain(case1.id);
expect(caseIDsWithAlert).to.contain(case2.id);
expect(caseIDsWithAlert).to.contain(case3.id);
});
it('should return all cases with the same alert ID when more than 100 cases', async () => {
// if there are more than 100 responses, the implementation sets the aggregation size to the
// specific value
const numCases = 102;
const createCasePromises: Array<Promise<CaseResponse>> = [];
for (let i = 0; i < numCases; i++) {
createCasePromises.push(createCase());
}
const cases = await Promise.all(createCasePromises);
const commentPromises: Array<Promise<void>> = [];
for (const caseInfo of cases) {
commentPromises.push(createComment(caseInfo.id));
}
await Promise.all(commentPromises);
const { body: caseIDsWithAlert } = await supertest
.get(`${CASES_URL}/alerts/test-id`)
.expect(200);
expect(caseIDsWithAlert.length).to.eql(numCases);
for (const caseInfo of cases) {
expect(caseIDsWithAlert).to.contain(caseInfo.id);
}
});
it('should return no cases when the alert ID is not found', async () => {
const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]);
await Promise.all([
createComment(case1.id),
createComment(case2.id),
createComment(case3.id),
]);
const { body: caseIDsWithAlert } = await supertest
.get(`${CASES_URL}/alerts/test-id100`)
.expect(200);
expect(caseIDsWithAlert.length).to.eql(0);
});
it('should return a 302 when passing an empty alertID', async () => {
// kibana returns a 302 instead of a 400 when a url param is missing
await supertest.get(`${CASES_URL}/alerts/`).expect(302);
});
});
};

View file

@ -13,6 +13,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
// Fastest ciGroup for the moment.
this.tags('ciGroup5');
loadTestFile(require.resolve('./cases/alerts/get_cases'));
loadTestFile(require.resolve('./cases/comments/delete_comment'));
loadTestFile(require.resolve('./cases/comments/find_comments'));
loadTestFile(require.resolve('./cases/comments/get_comment'));