[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:
parent
669be33792
commit
5de608cad8
|
@ -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()),
|
||||
|
|
|
@ -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"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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('.');
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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'));
|
||||
|
|
Loading…
Reference in a new issue