Skip to content

Commit d51c148

Browse files
[SIEM] [Detection Engine] Reject if duplicate rule_id in request payload (#57057) (#57527)
* prevents creation of rules when duplicate rule_id is present * adds unit test to reflect change * genericizes duplicate discovery functions, allows creation of non-duplicated rules even when duplicates are discovered, keeps same return type signature, updates relevant test for duplicates in request payload * utilizes countBy and removes reduce in favor of a filter on getDuplicates function * fix type * removes skip from e2e test for duplicates on bulk create, updates expected response in e2e test, fixes bug where duplicated error messages appeared for each instance of a duplicated rule_id (found this one through the e2e tests)! Adds unit test to catch this case. * getDuplicate returns empty array instead of null, removes unnecessary return logic * removes null coalescing from includes in filter Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent 5d37ec6 commit d51c148

File tree

5 files changed

+156
-76
lines changed

5 files changed

+156
-76
lines changed

x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
} from '../__mocks__/request_responses';
2323
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
2424
import { createRulesBulkRoute } from './create_rules_bulk_route';
25+
import { BulkError } from '../utils';
26+
import { OutputRuleAlertRest } from '../../types';
2527

2628
describe('create_rules_bulk', () => {
2729
let { server, alertsClient, actionsClient, elasticsearch } = createMockServer();
@@ -142,4 +144,34 @@ describe('create_rules_bulk', () => {
142144
expect(statusCode).toBe(400);
143145
});
144146
});
147+
148+
test('returns 409 if duplicate rule_ids found in request payload', async () => {
149+
alertsClient.find.mockResolvedValue(getFindResult());
150+
alertsClient.get.mockResolvedValue(getResult());
151+
actionsClient.create.mockResolvedValue(createActionResult());
152+
alertsClient.create.mockResolvedValue(getResult());
153+
const request: ServerInjectOptions = {
154+
method: 'POST',
155+
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
156+
payload: [typicalPayload(), typicalPayload()],
157+
};
158+
const { payload } = await server.inject(request);
159+
const output: Array<BulkError | Partial<OutputRuleAlertRest>> = JSON.parse(payload);
160+
expect(output.some(item => item.error?.status_code === 409)).toBeTruthy();
161+
});
162+
163+
test('returns one error object in response when duplicate rule_ids found in request payload', async () => {
164+
alertsClient.find.mockResolvedValue(getFindResult());
165+
alertsClient.get.mockResolvedValue(getResult());
166+
actionsClient.create.mockResolvedValue(createActionResult());
167+
alertsClient.create.mockResolvedValue(getResult());
168+
const request: ServerInjectOptions = {
169+
method: 'POST',
170+
url: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`,
171+
payload: [typicalPayload(), typicalPayload()],
172+
};
173+
const { payload } = await server.inject(request);
174+
const output: Array<BulkError | Partial<OutputRuleAlertRest>> = JSON.parse(payload);
175+
expect(output.length).toBe(1);
176+
});
145177
});

x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts

Lines changed: 87 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
*/
66

77
import Hapi from 'hapi';
8-
import { isFunction } from 'lodash/fp';
8+
import { isFunction, countBy } from 'lodash/fp';
99
import uuid from 'uuid';
1010
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
1111
import { createRules } from '../../rules/create_rules';
1212
import { BulkRulesRequest } from '../../rules/types';
1313
import { ServerFacade } from '../../../../types';
1414
import { readRules } from '../../rules/read_rules';
15-
import { transformOrBulkError } from './utils';
15+
import { transformOrBulkError, getDuplicates } from './utils';
1616
import { getIndexExists } from '../../index/get_index_exists';
1717
import {
1818
callWithRequestFactory,
@@ -47,94 +47,109 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
4747
return headers.response().code(404);
4848
}
4949

50+
const ruleDefinitions = request.payload;
51+
const mappedDuplicates = countBy('rule_id', ruleDefinitions);
52+
const dupes = getDuplicates(mappedDuplicates);
53+
5054
const rules = await Promise.all(
51-
request.payload.map(async payloadRule => {
52-
const {
53-
description,
54-
enabled,
55-
false_positives: falsePositives,
56-
from,
57-
query,
58-
language,
59-
output_index: outputIndex,
60-
saved_id: savedId,
61-
meta,
62-
filters,
63-
rule_id: ruleId,
64-
index,
65-
interval,
66-
max_signals: maxSignals,
67-
risk_score: riskScore,
68-
name,
69-
severity,
70-
tags,
71-
threat,
72-
to,
73-
type,
74-
references,
75-
timeline_id: timelineId,
76-
timeline_title: timelineTitle,
77-
version,
78-
} = payloadRule;
79-
const ruleIdOrUuid = ruleId ?? uuid.v4();
80-
try {
81-
const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server);
82-
const callWithRequest = callWithRequestFactory(request, server);
83-
const indexExists = await getIndexExists(callWithRequest, finalIndex);
84-
if (!indexExists) {
85-
return createBulkErrorObject({
86-
ruleId: ruleIdOrUuid,
87-
statusCode: 400,
88-
message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`,
89-
});
90-
}
91-
if (ruleId != null) {
92-
const rule = await readRules({ alertsClient, ruleId });
93-
if (rule != null) {
94-
return createBulkErrorObject({
95-
ruleId,
96-
statusCode: 409,
97-
message: `rule_id: "${ruleId}" already exists`,
98-
});
99-
}
100-
}
101-
const createdRule = await createRules({
102-
alertsClient,
103-
actionsClient,
55+
ruleDefinitions
56+
.filter(rule => rule.rule_id == null || !dupes.includes(rule.rule_id))
57+
.map(async payloadRule => {
58+
const {
10459
description,
10560
enabled,
106-
falsePositives,
61+
false_positives: falsePositives,
10762
from,
108-
immutable: false,
10963
query,
11064
language,
111-
outputIndex: finalIndex,
112-
savedId,
113-
timelineId,
114-
timelineTitle,
65+
output_index: outputIndex,
66+
saved_id: savedId,
11567
meta,
11668
filters,
117-
ruleId: ruleIdOrUuid,
69+
rule_id: ruleId,
11870
index,
11971
interval,
120-
maxSignals,
121-
riskScore,
72+
max_signals: maxSignals,
73+
risk_score: riskScore,
12274
name,
12375
severity,
12476
tags,
77+
threat,
12578
to,
12679
type,
127-
threat,
12880
references,
81+
timeline_id: timelineId,
82+
timeline_title: timelineTitle,
12983
version,
130-
});
131-
return transformOrBulkError(ruleIdOrUuid, createdRule);
132-
} catch (err) {
133-
return transformBulkError(ruleIdOrUuid, err);
134-
}
135-
})
84+
} = payloadRule;
85+
const ruleIdOrUuid = ruleId ?? uuid.v4();
86+
try {
87+
const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server);
88+
const callWithRequest = callWithRequestFactory(request, server);
89+
const indexExists = await getIndexExists(callWithRequest, finalIndex);
90+
if (!indexExists) {
91+
return createBulkErrorObject({
92+
ruleId: ruleIdOrUuid,
93+
statusCode: 400,
94+
message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`,
95+
});
96+
}
97+
if (ruleId != null) {
98+
const rule = await readRules({ alertsClient, ruleId });
99+
if (rule != null) {
100+
return createBulkErrorObject({
101+
ruleId,
102+
statusCode: 409,
103+
message: `rule_id: "${ruleId}" already exists`,
104+
});
105+
}
106+
}
107+
const createdRule = await createRules({
108+
alertsClient,
109+
actionsClient,
110+
description,
111+
enabled,
112+
falsePositives,
113+
from,
114+
immutable: false,
115+
query,
116+
language,
117+
outputIndex: finalIndex,
118+
savedId,
119+
timelineId,
120+
timelineTitle,
121+
meta,
122+
filters,
123+
ruleId: ruleIdOrUuid,
124+
index,
125+
interval,
126+
maxSignals,
127+
riskScore,
128+
name,
129+
severity,
130+
tags,
131+
to,
132+
type,
133+
threat,
134+
references,
135+
version,
136+
});
137+
return transformOrBulkError(ruleIdOrUuid, createdRule);
138+
} catch (err) {
139+
return transformBulkError(ruleIdOrUuid, err);
140+
}
141+
})
136142
);
137-
return rules;
143+
return [
144+
...rules,
145+
...dupes.map(ruleId =>
146+
createBulkErrorObject({
147+
ruleId,
148+
statusCode: 409,
149+
message: `rule_id: "${ruleId}" already exists`,
150+
})
151+
),
152+
];
138153
},
139154
};
140155
};

x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
transformRulesToNdjson,
1616
transformAlertsToRules,
1717
transformOrImportError,
18+
getDuplicates,
1819
} from './utils';
1920
import { getResult } from '../__mocks__/request_responses';
2021
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
@@ -1202,4 +1203,25 @@ describe('utils', () => {
12021203
expect(output).toEqual(expected);
12031204
});
12041205
});
1206+
1207+
describe('getDuplicates', () => {
1208+
test("returns array of ruleIds showing the duplicate keys of 'value2' and 'value3'", () => {
1209+
const output = getDuplicates({
1210+
value1: 1,
1211+
value2: 2,
1212+
value3: 2,
1213+
});
1214+
const expected = ['value2', 'value3'];
1215+
expect(output).toEqual(expected);
1216+
});
1217+
test('returns null when given a map of no duplicates', () => {
1218+
const output = getDuplicates({
1219+
value1: 1,
1220+
value2: 1,
1221+
value3: 1,
1222+
});
1223+
const expected: string[] = [];
1224+
expect(output).toEqual(expected);
1225+
});
1226+
});
12051227
});

x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { pickBy } from 'lodash/fp';
8+
import { Dictionary } from 'lodash';
89
import { SavedObject } from 'kibana/server';
910
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
1011
import {
@@ -215,3 +216,11 @@ export const transformOrImportError = (
215216
});
216217
}
217218
};
219+
220+
export const getDuplicates = (lodashDict: Dictionary<number>): string[] => {
221+
const hasDuplicates = Object.values(lodashDict).some(i => i > 1);
222+
if (hasDuplicates) {
223+
return Object.keys(lodashDict).filter(key => lodashDict[key] > 1);
224+
}
225+
return [];
226+
};

x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default ({ getService }: FtrProviderContext): void => {
8080
});
8181

8282
// TODO: This is a valid issue and will be fixed in an upcoming PR and then enabled once that PR is merged
83-
it.skip('should return a 200 ok but have a 409 conflict if we attempt to create the same rule_id twice', async () => {
83+
it('should return a 200 ok but have a 409 conflict if we attempt to create the same rule_id twice', async () => {
8484
const { body } = await supertest
8585
.post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`)
8686
.set('kbn-xsrf', 'true')
@@ -89,9 +89,11 @@ export default ({ getService }: FtrProviderContext): void => {
8989

9090
expect(body).to.eql([
9191
{
92-
error: 'Conflict',
93-
message: 'rule_id: "rule-1" already exists',
94-
statusCode: 409,
92+
error: {
93+
message: 'rule_id: "rule-1" already exists',
94+
status_code: 409,
95+
},
96+
rule_id: 'rule-1',
9597
},
9698
]);
9799
});

0 commit comments

Comments
 (0)