Skip to content

Commit f183117

Browse files
committed
Refresh Kibana module with API updates
1 parent 5420537 commit f183117

File tree

4 files changed

+166
-6
lines changed

4 files changed

+166
-6
lines changed

detection_rules/kbwrap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def upload_rule(ctx, rules, replace_id):
5353
api_payloads.append(rule)
5454

5555
with kibana:
56-
results = RuleResource.bulk_create(api_payloads)
56+
results = RuleResource.bulk_create_legacy(api_payloads)
5757

5858
success = []
5959
errors = []

kibana/connector.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import sys
1111
import threading
1212
import uuid
13+
from typing import List, Optional, Union
1314

1415
import requests
1516
from elasticsearch import Elasticsearch
@@ -72,6 +73,16 @@ def version(self):
7273
if self.status:
7374
return self.status.get("version", {}).get("number")
7475

76+
@staticmethod
77+
def ndjson_file_data_prep(lines: List[dict], filename: str) -> (dict, str):
78+
"""Prepare a request for an ndjson file upload to Kibana."""
79+
data = ('\n'.join(json.dumps(r) for r in lines) + '\n')
80+
boundary = '----JustAnotherBoundary'
81+
bounded_data = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
82+
f'Content-Type: application/x-ndjson\r\n\r\n{data}\r\n--{boundary}--\r\n').encode('utf-8')
83+
headers = {'content-type': f'multipart/form-data; boundary={boundary}'}
84+
return headers, bounded_data
85+
7586
def url(self, uri):
7687
"""Get the full URL given a URI."""
7788
assert self.kibana_url is not None
@@ -81,15 +92,20 @@ def url(self, uri):
8192
uri = "s/{}/{}".format(self.space, uri)
8293
return f"{self.kibana_url}/{uri}"
8394

84-
def request(self, method, uri, params=None, data=None, error=True, verbose=True, raw=False, **kwargs):
95+
def request(self, method, uri, params=None, data=None, raw_data=None, error=True, verbose=True, raw=False,
96+
**kwargs) -> Optional[Union[requests.Response, dict]]:
8597
"""Perform a RESTful HTTP request with JSON responses."""
86-
params = params or {}
8798
url = self.url(uri)
88-
params = {k: v for k, v in params.items()}
99+
params = params or {}
100+
params = json.dumps(params)
89101
body = None
90102
if data is not None:
91103
body = json.dumps(data)
92104

105+
assert not (body and raw_data), "Cannot provide both data and raw_data"
106+
107+
body = body or raw_data
108+
93109
response = self.session.request(method, url, params=params, data=body, **kwargs)
94110

95111
if response.status_code != 200:
@@ -100,14 +116,16 @@ def request(self, method, uri, params=None, data=None, error=True, verbose=True,
100116
try:
101117
response.raise_for_status()
102118
except requests.exceptions.HTTPError:
119+
if response.status_code == 404:
120+
raise NotImplementedError(f'API endpoint {uri} not implemented for Kibana version {self.version}')
103121
if verbose:
104122
print(response.content.decode("utf-8"), file=sys.stderr)
105123
raise
106124

107125
if not response.content:
108126
return
109127

110-
return response.content if raw else response.json()
128+
return response if raw else response.json()
111129

112130
def get(self, uri, params=None, data=None, error=True, **kwargs):
113131
"""Perform an HTTP GET."""

kibana/defenitions.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
# or more contributor license agreements. Licensed under the Elastic License
3+
# 2.0; you may not use this file except in compliance with the Elastic License
4+
# 2.0.
5+
6+
from dataclasses import dataclass
7+
from typing import Any, Dict, List, Literal, NewType, Union
8+
9+
10+
RuleBulkActions = Literal['enable', 'disable', 'delete', 'duplicate', 'export', 'edit']
11+
12+
RuleBulkAddTags = NewType('RuleBulkAddTags', List[str])
13+
RuleBulkDeleteTags = NewType('RuleBulkDeleteTags', List[str])
14+
RuleBulkSetTags = NewType('RuleBulkSetTags', List[str])
15+
RuleBulkAddIndexPatterns = NewType('RuleBulkAddIndexPatterns', List[str])
16+
RuleBulkDeleteIndexPatterns = NewType('RuleBulkDeleteIndexPatterns', List[str])
17+
RuleBulkSetIndexPatterns = NewType('RuleBulkSetIndexPatterns', List[str])
18+
RuleBulkSetTimelineTitle = NewType('RuleBulkSetTimelineTitle', Dict[Literal['timeline_id', 'timeline_title'], str])
19+
RuleBulkSetSchedule = NewType('RuleBulkSetSchedule', Dict[Literal['interval', 'lookback'], str])
20+
RuleBulkAddRuleActions = NewType('RuleBulkAddRuleActions', Dict[Literal['actions', 'throttle'], Union[List[dict], dict]])
21+
RuleBulkSetRuleActions = NewType('RuleBulkSetRuleActions', Dict[Literal['actions', 'throttle'], Union[List[dict], dict]])
22+
23+
RuleBulkEditActionTypes = Union[
24+
RuleBulkAddTags,
25+
RuleBulkDeleteTags,
26+
RuleBulkSetTags,
27+
RuleBulkAddIndexPatterns,
28+
RuleBulkDeleteIndexPatterns,
29+
RuleBulkSetIndexPatterns,
30+
RuleBulkSetTimelineTitle,
31+
RuleBulkSetSchedule,
32+
RuleBulkAddRuleActions,
33+
RuleBulkSetRuleActions
34+
]
35+
36+
@dataclass
37+
class RuleBulkEditAction:
38+
type: RuleBulkEditActionTypes
39+
value: Any
40+
41+
42+
@dataclass
43+
class RuleBulkDuplicateAction:
44+
include_exceptions: bool

kibana/resources.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import datetime
77
from typing import List, Optional, Type
88

9+
import json
10+
911
from .connector import Kibana
12+
from . import defenitions
1013

1114
DEFAULT_PAGE_SIZE = 10
1215

@@ -20,10 +23,12 @@ def id(self):
2023
return self.get(self.ID_FIELD)
2124

2225
@classmethod
23-
def bulk_create(cls, resources: list):
26+
def bulk_create_legacy(cls, resources: list):
2427
for r in resources:
2528
assert isinstance(r, cls)
2629

30+
# _bulk_create is being deprecated. Leave for backwards compat only
31+
# the new API would be import with multiple rules within an ndjson request
2732
responses = Kibana.current().post(cls.BASE_URI + "/_bulk_create", data=resources)
2833
return [cls(r) for r in responses]
2934

@@ -127,6 +132,64 @@ def find_elastic(cls, **params):
127132
params = cls._add_internal_filter(True, params)
128133
return cls.find(**params)
129134

135+
@classmethod
136+
def bulk_action(cls, action: defenitions.RuleBulkActions, rule_ids: Optional[List[str]] = None,
137+
query: Optional[str] = None, dry_run: Optional[bool] = False,
138+
edit: Optional[defenitions.RuleBulkEditAction] = None,
139+
duplicate: Optional[defenitions.RuleBulkDuplicateAction] = None) -> (dict, List['RuleResource']):
140+
assert not (rule_ids and query), 'Cannot provide both rule_ids and query'
141+
142+
if action == 'edit':
143+
assert edit, 'edit action requires edit object'
144+
145+
params = dict(dry_run=dry_run)
146+
data = dict(query=query, ids=rule_ids, action=action, edit=edit, duplicate=duplicate)
147+
response = Kibana.current().post(cls.BASE_URI + "/_bulk_action", params=params, data=data)
148+
149+
results = response['attributes']['results']
150+
result_ids = [r['rule_id'] for r in results['updated']]
151+
result_ids.extend([r['rule_id'] for r in results['created']])
152+
rule_resources = cls.export_rules(result_ids)
153+
return response, rule_resources
154+
155+
@classmethod
156+
def bulk_enable(cls, rule_ids: Optional[List[str]] = None,
157+
query: Optional[str] = None, dry_run: Optional[bool] = False) -> (dict, List['RuleResource']):
158+
"""Bulk enable rules using _bulk_action."""
159+
return cls.bulk_action("enable", rule_ids=rule_ids, query=query, dry_run=dry_run)
160+
161+
@classmethod
162+
def bulk_disable(cls, rule_ids: Optional[List[str]] = None,
163+
query: Optional[str] = None, dry_run: Optional[bool] = False) -> (dict, List['RuleResource']):
164+
"""Bulk disable rules using _bulk_action."""
165+
return cls.bulk_action("disable", rule_ids=rule_ids, query=query, dry_run=dry_run)
166+
167+
@classmethod
168+
def bulk_delete(cls,rule_ids: Optional[List[str]] = None,
169+
query: Optional[str] = None, dry_run: Optional[bool] = False) -> (dict, List['RuleResource']):
170+
"""Bulk delete rules using _bulk_action."""
171+
return cls.bulk_action("delete", rule_ids=rule_ids, query=query, dry_run=dry_run)
172+
173+
@classmethod
174+
def bulk_duplicate(cls, rule_ids: Optional[List[str]] = None,
175+
query: Optional[str] = None, dry_run: Optional[bool] = False,
176+
duplicate: Optional[defenitions.RuleBulkDuplicateAction] = None) -> (dict, List['RuleResource']):
177+
"""Bulk duplicate rules using _bulk_action."""
178+
return cls.bulk_action("duplicate", rule_ids=rule_ids, query=query, dry_run=dry_run, duplicate=duplicate)
179+
180+
@classmethod
181+
def bulk_export(cls, rule_ids: Optional[List[str]] = None,
182+
query: Optional[str] = None, dry_run: Optional[bool] = False) -> (dict, List['RuleResource']):
183+
"""Bulk export rules using _bulk_action."""
184+
return cls.bulk_action("export", rule_ids=rule_ids, query=query, dry_run=dry_run)
185+
186+
@classmethod
187+
def bulk_edit(cls, rule_ids: Optional[List[str]] = None,
188+
query: Optional[str] = None, dry_run: Optional[bool] = False,
189+
edit: Optional[defenitions.RuleBulkEditAction] = None) -> (dict, List['RuleResource']):
190+
"""Bulk edit rules using _bulk_action."""
191+
return cls.bulk_action("edit", rule_ids=rule_ids, query=query, dry_run=dry_run, edit=edit)
192+
130193
def put(self):
131194
# id and rule_id are mutually exclusive
132195
rule_id = self.get("rule_id")
@@ -142,6 +205,41 @@ def put(self):
142205

143206
raise
144207

208+
@classmethod
209+
def import_rules(cls, rules: List[dict], overwrite: bool = False, overwrite_exceptions: bool = False,
210+
overwrite_action_connectors: bool = False) -> (dict, List['RuleResource']):
211+
"""Import a list of rules into Kibana using the _import API and return the response and successful imports."""
212+
url = f'{cls.BASE_URI}/_import'
213+
params = dict(
214+
overwrite=overwrite,
215+
overwrite_exceptions=overwrite_exceptions,
216+
overwrite_action_connectors=overwrite_action_connectors
217+
)
218+
rule_ids = [r['rule_id'] for r in rules]
219+
headers, raw_data = Kibana.ndjson_file_data_prep(rules, "import.ndjson")
220+
response = Kibana.current().post(url, headers=headers, params=params, raw_data=raw_data)
221+
errors = response.get("errors", [])
222+
error_rule_ids = [e['rule_id'] for e in errors]
223+
224+
# successful rule_ids are not returned, so they must be implicitly inferred from errored rule_ids
225+
successful_rule_ids = [r for r in rule_ids if r not in error_rule_ids]
226+
rule_resources = cls.export_rules(successful_rule_ids)
227+
return response, rule_resources
228+
229+
@classmethod
230+
def export_rules(cls, rule_ids: Optional[List[str]] = None,
231+
exclude_export_details: bool = False) -> List['RuleResource']:
232+
"""Export a list of rules from Kibana using the _export API."""
233+
url = f'{cls.BASE_URI}/_export'
234+
235+
if rule_ids:
236+
rule_ids = {'objects': [{'rule_id': r} for r in rule_ids]}
237+
238+
params = dict(exclude_export_details=exclude_export_details)
239+
response = Kibana.current().post(url, params=params, data=rule_ids, raw=True)
240+
data = [json.loads(r) for r in response.text.splitlines()]
241+
return [cls(r) for r in data]
242+
145243

146244
class Signal(BaseResource):
147245
BASE_URI = "/api/detection_engine/signals"

0 commit comments

Comments
 (0)