howler-api 3.0.0.dev374__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (198) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +168 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/clue.py +99 -0
  21. howler/api/v1/configs.py +58 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +788 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +416 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +125 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/loader.py +154 -0
  41. howler/common/logging/__init__.py +241 -0
  42. howler/common/logging/audit.py +138 -0
  43. howler/common/logging/format.py +38 -0
  44. howler/common/net.py +79 -0
  45. howler/common/net_static.py +1494 -0
  46. howler/common/random_user.py +316 -0
  47. howler/common/swagger.py +117 -0
  48. howler/config.py +64 -0
  49. howler/cronjobs/__init__.py +29 -0
  50. howler/cronjobs/retention.py +61 -0
  51. howler/cronjobs/rules.py +274 -0
  52. howler/cronjobs/view_cleanup.py +88 -0
  53. howler/datastore/README.md +112 -0
  54. howler/datastore/__init__.py +0 -0
  55. howler/datastore/bulk.py +72 -0
  56. howler/datastore/collection.py +2342 -0
  57. howler/datastore/constants.py +119 -0
  58. howler/datastore/exceptions.py +41 -0
  59. howler/datastore/howler_store.py +105 -0
  60. howler/datastore/migrations/fix_process.py +41 -0
  61. howler/datastore/operations.py +130 -0
  62. howler/datastore/schemas.py +90 -0
  63. howler/datastore/store.py +231 -0
  64. howler/datastore/support/__init__.py +0 -0
  65. howler/datastore/support/build.py +215 -0
  66. howler/datastore/support/schemas.py +90 -0
  67. howler/datastore/types.py +22 -0
  68. howler/error.py +91 -0
  69. howler/external/__init__.py +0 -0
  70. howler/external/generate_mitre.py +96 -0
  71. howler/external/generate_sigma_rules.py +31 -0
  72. howler/external/generate_tlds.py +47 -0
  73. howler/external/reindex_data.py +66 -0
  74. howler/external/wipe_databases.py +58 -0
  75. howler/gunicorn_config.py +25 -0
  76. howler/healthz.py +47 -0
  77. howler/helper/__init__.py +0 -0
  78. howler/helper/azure.py +50 -0
  79. howler/helper/discover.py +59 -0
  80. howler/helper/hit.py +236 -0
  81. howler/helper/oauth.py +247 -0
  82. howler/helper/search.py +92 -0
  83. howler/helper/workflow.py +110 -0
  84. howler/helper/ws.py +378 -0
  85. howler/odm/README.md +102 -0
  86. howler/odm/__init__.py +1 -0
  87. howler/odm/base.py +1543 -0
  88. howler/odm/charter.txt +146 -0
  89. howler/odm/helper.py +416 -0
  90. howler/odm/howler_enum.py +25 -0
  91. howler/odm/models/__init__.py +0 -0
  92. howler/odm/models/action.py +33 -0
  93. howler/odm/models/analytic.py +90 -0
  94. howler/odm/models/assemblyline.py +48 -0
  95. howler/odm/models/aws.py +23 -0
  96. howler/odm/models/azure.py +16 -0
  97. howler/odm/models/cbs.py +44 -0
  98. howler/odm/models/config.py +558 -0
  99. howler/odm/models/dossier.py +33 -0
  100. howler/odm/models/ecs/__init__.py +0 -0
  101. howler/odm/models/ecs/agent.py +17 -0
  102. howler/odm/models/ecs/autonomous_system.py +16 -0
  103. howler/odm/models/ecs/client.py +149 -0
  104. howler/odm/models/ecs/cloud.py +141 -0
  105. howler/odm/models/ecs/code_signature.py +27 -0
  106. howler/odm/models/ecs/container.py +32 -0
  107. howler/odm/models/ecs/dns.py +62 -0
  108. howler/odm/models/ecs/egress.py +10 -0
  109. howler/odm/models/ecs/elf.py +74 -0
  110. howler/odm/models/ecs/email.py +122 -0
  111. howler/odm/models/ecs/error.py +14 -0
  112. howler/odm/models/ecs/event.py +140 -0
  113. howler/odm/models/ecs/faas.py +24 -0
  114. howler/odm/models/ecs/file.py +84 -0
  115. howler/odm/models/ecs/geo.py +30 -0
  116. howler/odm/models/ecs/group.py +18 -0
  117. howler/odm/models/ecs/hash.py +16 -0
  118. howler/odm/models/ecs/host.py +17 -0
  119. howler/odm/models/ecs/http.py +37 -0
  120. howler/odm/models/ecs/ingress.py +12 -0
  121. howler/odm/models/ecs/interface.py +21 -0
  122. howler/odm/models/ecs/network.py +30 -0
  123. howler/odm/models/ecs/observer.py +45 -0
  124. howler/odm/models/ecs/organization.py +12 -0
  125. howler/odm/models/ecs/os.py +21 -0
  126. howler/odm/models/ecs/pe.py +17 -0
  127. howler/odm/models/ecs/process.py +216 -0
  128. howler/odm/models/ecs/registry.py +26 -0
  129. howler/odm/models/ecs/related.py +45 -0
  130. howler/odm/models/ecs/rule.py +51 -0
  131. howler/odm/models/ecs/server.py +24 -0
  132. howler/odm/models/ecs/threat.py +247 -0
  133. howler/odm/models/ecs/tls.py +58 -0
  134. howler/odm/models/ecs/url.py +51 -0
  135. howler/odm/models/ecs/user.py +57 -0
  136. howler/odm/models/ecs/user_agent.py +20 -0
  137. howler/odm/models/ecs/vulnerability.py +41 -0
  138. howler/odm/models/gcp.py +16 -0
  139. howler/odm/models/hit.py +356 -0
  140. howler/odm/models/howler_data.py +328 -0
  141. howler/odm/models/lead.py +24 -0
  142. howler/odm/models/localized_label.py +13 -0
  143. howler/odm/models/overview.py +16 -0
  144. howler/odm/models/pivot.py +40 -0
  145. howler/odm/models/template.py +24 -0
  146. howler/odm/models/user.py +83 -0
  147. howler/odm/models/view.py +34 -0
  148. howler/odm/random_data.py +888 -0
  149. howler/odm/randomizer.py +609 -0
  150. howler/patched.py +5 -0
  151. howler/plugins/__init__.py +25 -0
  152. howler/plugins/config.py +123 -0
  153. howler/remote/__init__.py +0 -0
  154. howler/remote/datatypes/README.md +355 -0
  155. howler/remote/datatypes/__init__.py +98 -0
  156. howler/remote/datatypes/counters.py +63 -0
  157. howler/remote/datatypes/events.py +66 -0
  158. howler/remote/datatypes/hash.py +206 -0
  159. howler/remote/datatypes/lock.py +42 -0
  160. howler/remote/datatypes/queues/__init__.py +0 -0
  161. howler/remote/datatypes/queues/comms.py +59 -0
  162. howler/remote/datatypes/queues/multi.py +32 -0
  163. howler/remote/datatypes/queues/named.py +93 -0
  164. howler/remote/datatypes/queues/priority.py +215 -0
  165. howler/remote/datatypes/set.py +118 -0
  166. howler/remote/datatypes/user_quota_tracker.py +54 -0
  167. howler/security/__init__.py +253 -0
  168. howler/security/socket.py +108 -0
  169. howler/security/utils.py +185 -0
  170. howler/services/__init__.py +0 -0
  171. howler/services/action_service.py +111 -0
  172. howler/services/analytic_service.py +128 -0
  173. howler/services/auth_service.py +323 -0
  174. howler/services/config_service.py +128 -0
  175. howler/services/dossier_service.py +252 -0
  176. howler/services/event_service.py +93 -0
  177. howler/services/hit_service.py +893 -0
  178. howler/services/jwt_service.py +158 -0
  179. howler/services/lucene_service.py +286 -0
  180. howler/services/notebook_service.py +119 -0
  181. howler/services/overview_service.py +44 -0
  182. howler/services/template_service.py +45 -0
  183. howler/services/user_service.py +331 -0
  184. howler/utils/__init__.py +0 -0
  185. howler/utils/annotations.py +28 -0
  186. howler/utils/chunk.py +38 -0
  187. howler/utils/dict_utils.py +200 -0
  188. howler/utils/isotime.py +17 -0
  189. howler/utils/list_utils.py +11 -0
  190. howler/utils/lucene.py +77 -0
  191. howler/utils/path.py +27 -0
  192. howler/utils/socket_utils.py +61 -0
  193. howler/utils/str_utils.py +256 -0
  194. howler/utils/uid.py +47 -0
  195. howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
  196. howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
  197. howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
  198. howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
@@ -0,0 +1,748 @@
1
+ import typing
2
+ from typing import Any, Optional
3
+
4
+ from flask import Response, request
5
+
6
+ from howler.api import (
7
+ bad_request,
8
+ forbidden,
9
+ make_subapi_blueprint,
10
+ no_content,
11
+ not_found,
12
+ ok,
13
+ )
14
+ from howler.common.exceptions import HowlerException
15
+ from howler.common.loader import datastore
16
+ from howler.common.logging import get_logger
17
+ from howler.common.swagger import generate_swagger_docs
18
+ from howler.cronjobs.rules import register_rules
19
+ from howler.datastore.exceptions import DataStoreException
20
+ from howler.datastore.operations import OdmHelper
21
+ from howler.odm.models.analytic import Analytic, Comment, Notebook, TriageOptions
22
+ from howler.odm.models.template import Template
23
+ from howler.odm.models.user import User
24
+ from howler.security import api_login
25
+ from howler.services import analytic_service, user_service
26
+
27
+ MAX_COMMENT_LEN = 5000
28
+ SUB_API = "analytic"
29
+ analytic_api = make_subapi_blueprint(SUB_API, api_version=1)
30
+ analytic_api._doc = "Manage the analytics that create hits"
31
+
32
+ logger = get_logger(__file__)
33
+
34
+ analytic_helper = OdmHelper(Analytic)
35
+
36
+
37
+ @generate_swagger_docs()
38
+ @analytic_api.route("/", methods=["GET"])
39
+ @api_login(required_priv=["R"])
40
+ def get_analytics(**kwargs: Any) -> Response:
41
+ """Get a list of analytics used to create hits in howler
42
+
43
+ Variables:
44
+ None
45
+
46
+ Optional Arguments:
47
+ None
48
+
49
+ Result Example:
50
+ [
51
+ ...analytics # A list of analytics
52
+ ]
53
+ """
54
+ return ok(datastore().analytic.search("*:*", as_obj=False, rows=1000)["items"])
55
+
56
+
57
+ @generate_swagger_docs()
58
+ @analytic_api.route("/<id>", methods=["GET"])
59
+ @api_login(required_priv=["R"])
60
+ def get_analytic(id, **kwargs):
61
+ """Get a specific analytic
62
+
63
+ Variables:
64
+ id => The id of the analytic to retrieve
65
+
66
+ Optional Arguments:
67
+ None
68
+
69
+ Result Example:
70
+ {
71
+ ...analytic # The requested analytic
72
+ }
73
+ """
74
+ try:
75
+ if not analytic_service.does_analytic_exist(id):
76
+ return not_found(err="Analytic does not exist")
77
+
78
+ return ok(analytic_service.get_analytic(id, as_obj=False))
79
+ except ValueError as e:
80
+ return bad_request(err=str(e))
81
+
82
+
83
+ @generate_swagger_docs()
84
+ @analytic_api.route("/<id>", methods=["PUT"])
85
+ @api_login(required_priv=["R", "W"])
86
+ def update_analytic(id: str, user: User, **kwargs):
87
+ """Update an analytic
88
+
89
+ Variables:
90
+ id => The id of the analytic to modify
91
+
92
+ Optional Arguments:
93
+ None
94
+
95
+ Data Block:
96
+ {
97
+ ...analytic # The new data to add
98
+ }
99
+
100
+ Result Example:
101
+ {
102
+ ...analytic # The updated analytic data
103
+ }
104
+ """
105
+ storage = datastore()
106
+
107
+ if not storage.analytic.exists(id):
108
+ return not_found(err="This analytic does not exist")
109
+
110
+ new_data = request.json
111
+
112
+ if not new_data:
113
+ return bad_request(err="You must provide updated data.")
114
+
115
+ try:
116
+ existing_analytic: Analytic = storage.analytic.get_if_exists(id)
117
+
118
+ existing_analytic.description = new_data.get("description", existing_analytic.description)
119
+
120
+ if existing_analytic.triage_settings is not None:
121
+ existing_triage_data = existing_analytic.triage_settings.as_primitives()
122
+ else:
123
+ existing_triage_data = {}
124
+
125
+ existing_analytic.triage_settings = TriageOptions(
126
+ {**existing_triage_data, **new_data.get("triage_settings", {})}
127
+ )
128
+
129
+ updated_rule = False
130
+ if existing_analytic.rule_type:
131
+ updated_rule = existing_analytic.rule != new_data.get(
132
+ "rule", existing_analytic.rule
133
+ ) or existing_analytic.rule_crontab != new_data.get("rule_crontab", existing_analytic.rule_crontab)
134
+
135
+ existing_analytic.rule = new_data.get("rule", existing_analytic.rule)
136
+ existing_analytic.rule_crontab = new_data.get("rule_crontab", existing_analytic.rule_crontab)
137
+
138
+ storage.analytic.save(existing_analytic.analytic_id, existing_analytic)
139
+
140
+ if updated_rule:
141
+ # The registration process automatically deletes and resets the rule cronjob
142
+ register_rules(existing_analytic)
143
+
144
+ return ok(existing_analytic)
145
+ except HowlerException as e:
146
+ return bad_request(err=str(e))
147
+
148
+
149
+ @generate_swagger_docs()
150
+ @analytic_api.route("/rules", methods=["POST"])
151
+ @api_login(required_priv=["R", "W"])
152
+ def create_rule(user: User, **kwargs):
153
+ """Create a rule analytic
154
+
155
+ Variables:
156
+ None
157
+
158
+ Optional Arguments:
159
+ None
160
+
161
+ Data Block:
162
+ {
163
+ "name": "Rule Name",
164
+ "description": "*markdown* _description_"
165
+ }
166
+
167
+ Result Example:
168
+ {
169
+ ...analytic # The created analytic rule
170
+ }
171
+ """
172
+ storage = datastore()
173
+
174
+ new_data: Optional[dict[str, Any]] = request.json
175
+
176
+ if not new_data:
177
+ return bad_request(err="You must provide rule data.")
178
+
179
+ required_keys = {
180
+ "name",
181
+ "description",
182
+ "rule",
183
+ "rule_type",
184
+ "rule_crontab",
185
+ }
186
+
187
+ for key in required_keys:
188
+ if key not in new_data or not new_data[key]:
189
+ return bad_request(err=f"You must provide a {key} for your rule.")
190
+
191
+ extra_keys = set(new_data.keys()) - required_keys
192
+
193
+ if len(extra_keys) > 0:
194
+ return bad_request(err=f"Additional fields ({', '.join(extra_keys)}) are not permitted.")
195
+
196
+ new_analytic = Analytic(
197
+ {
198
+ **new_data,
199
+ "tags": ["rule"],
200
+ "owner": user["uname"],
201
+ "contributors": [user["uname"]],
202
+ "detections": ["Rule"],
203
+ }
204
+ )
205
+
206
+ new_template = Template(
207
+ {
208
+ "analytic": new_data["name"],
209
+ "detection": "Rule",
210
+ "type": "global",
211
+ "owner": user["uname"],
212
+ # TODO: Allow custom keys
213
+ "keys": ["event.kind", "event.module", "event.reason", "event.type"],
214
+ }
215
+ )
216
+
217
+ try:
218
+ storage.analytic.save(new_analytic.analytic_id, new_analytic)
219
+ # Have to commit so the analytic is available during registration
220
+ storage.analytic.commit()
221
+ register_rules(new_analytic)
222
+
223
+ storage.template.save(new_template.template_id, new_template)
224
+
225
+ return ok(new_analytic)
226
+ except HowlerException as e:
227
+ return bad_request(err=str(e))
228
+
229
+
230
+ @generate_swagger_docs()
231
+ @analytic_api.route("/<id>", methods=["DELETE"])
232
+ @api_login(audit=False, required_priv=["W"])
233
+ def delete_rule(id: str, user: User, **kwargs):
234
+ """Delete a rule
235
+
236
+ Variables:
237
+ id => id of the analytic whose comments we are deleting
238
+
239
+ Optional Arguments:
240
+ None
241
+
242
+ Data Block:
243
+ [
244
+ ...comment_ids
245
+ ]
246
+
247
+ Result Example:
248
+ {
249
+ }
250
+ """
251
+ if not analytic_service.does_analytic_exist(id):
252
+ return not_found(err=f"Analytic {id} does not exist")
253
+
254
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
255
+
256
+ if not analytic.rule:
257
+ return bad_request(err="This is not a rule analytic, and cannot be deleted.")
258
+
259
+ if user["uname"] != analytic.owner and "admin" not in user["type"]:
260
+ return forbidden(err="You cannot delete this analytic.")
261
+
262
+ try:
263
+ datastore().analytic.delete(analytic.analytic_id)
264
+ except DataStoreException as e:
265
+ return bad_request(err=str(e))
266
+
267
+ return no_content()
268
+
269
+
270
+ @generate_swagger_docs()
271
+ @analytic_api.route("/<id>/comments", methods=["POST"])
272
+ @api_login(audit=False, required_priv=["W"])
273
+ def add_comment(id: str, user: dict[str, Any], **kwargs):
274
+ """Add a comment
275
+
276
+ Variables:
277
+ id => id of the analytic to add a comment to
278
+
279
+ Optional Arguments:
280
+ None
281
+
282
+ Data Block:
283
+ {
284
+ detection: "Detection to comment on (optional)",
285
+ value: "New comment value"
286
+ }
287
+
288
+ Result Example:
289
+ {
290
+ ...analytic # The new data for the analytic
291
+ }
292
+ """
293
+ comment = request.json
294
+ if not isinstance(comment, dict):
295
+ return bad_request(err="Incorrect data format!")
296
+
297
+ comment_data = comment.get("value")
298
+ if not comment_data:
299
+ return bad_request(err="Value cannot be empty.")
300
+
301
+ if len(comment_data) > MAX_COMMENT_LEN:
302
+ return bad_request(err="Comment is too long.")
303
+
304
+ if not analytic_service.does_analytic_exist(id):
305
+ return not_found(err="Analytic %s does not exist" % id)
306
+
307
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
308
+
309
+ try:
310
+ analytic.comment.append(
311
+ Comment(
312
+ {
313
+ "user": user["uname"],
314
+ "value": comment_data,
315
+ "detection": comment.get("detection", None),
316
+ }
317
+ )
318
+ )
319
+
320
+ datastore().analytic.save(analytic.analytic_id, analytic)
321
+ except DataStoreException as e:
322
+ return bad_request(err=str(e))
323
+
324
+ analytic = analytic_service.get_analytic(id)
325
+
326
+ return ok(analytic)
327
+
328
+
329
+ @generate_swagger_docs()
330
+ @analytic_api.route("/<id>/comments/<comment_id>", methods=["PUT"])
331
+ @api_login(audit=False, required_priv=["W"])
332
+ def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
333
+ """Edit a comment
334
+
335
+ Variables:
336
+ id => id of the analytic the comment belongs to
337
+ comment_id => id of the comment we are editing
338
+
339
+ Optional Arguments:
340
+ None
341
+
342
+ Data Block:
343
+ {
344
+ value: "New comment value"
345
+ }
346
+
347
+ Result Example:
348
+ {
349
+ ...analytic # The new data for the analytic
350
+ }
351
+ """
352
+ updated_comment = request.json
353
+ if not isinstance(updated_comment, dict):
354
+ return bad_request(err="Incorrect data format")
355
+
356
+ if not analytic_service.does_analytic_exist(id):
357
+ return not_found(err=f"Analytic {id} does not exist")
358
+
359
+ comment_data: Optional[str] = updated_comment.get("value")
360
+ if not comment_data:
361
+ return bad_request(err="Value cannot be empty.")
362
+
363
+ if len(comment_data) > MAX_COMMENT_LEN:
364
+ return bad_request(err="Comment is too long.")
365
+
366
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
367
+
368
+ comment: Optional[Comment] = next((c for c in analytic.comment if c.id == comment_id), None)
369
+
370
+ if not comment:
371
+ return not_found(err=f"Comment {comment_id} does not exist")
372
+
373
+ if comment.user != user["uname"]:
374
+ return forbidden(err="Cannot edit comment that wasn't made by you.")
375
+
376
+ comment["value"] = comment_data
377
+ comment["modified"] = "NOW"
378
+
379
+ analytic.comment = [c if c.id != comment.id else comment for c in analytic.comment]
380
+
381
+ try:
382
+ datastore().analytic.save(analytic.analytic_id, analytic)
383
+ except DataStoreException as e:
384
+ return bad_request(err=str(e))
385
+
386
+ return ok(analytic)
387
+
388
+
389
+ @generate_swagger_docs()
390
+ @analytic_api.route("/<id>/comments/<comment_id>/react", methods=["PUT"])
391
+ @api_login(audit=False, required_priv=["W"])
392
+ def react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
393
+ """React to a comment
394
+
395
+ Variables:
396
+ id => id of the analytic the comment belongs to
397
+ comment_id => id of the comment we are editing
398
+
399
+ Optional Arguments:
400
+ None
401
+
402
+ Data Block:
403
+ {
404
+ type: "thumbsup"
405
+ }
406
+
407
+ Result Example:
408
+ {
409
+ ...analytic # The new data for the analytic
410
+ }
411
+ """
412
+ data = request.json
413
+ if not isinstance(data, dict):
414
+ return bad_request(err="Incorrect data format")
415
+
416
+ react_data: Optional[str] = data.get("type")
417
+ if not react_data:
418
+ return bad_request(err="Type cannot be empty.")
419
+
420
+ if not analytic_service.does_analytic_exist(id):
421
+ return not_found(err=f"Analytic {id} does not exist")
422
+
423
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
424
+
425
+ for comment in analytic.comment:
426
+ if comment.id == comment_id:
427
+ comment["reactions"] = {
428
+ **comment.get("reactions", {}),
429
+ user["uname"]: react_data,
430
+ }
431
+
432
+ datastore().analytic.save(analytic.analytic_id, analytic)
433
+
434
+ return ok(analytic)
435
+
436
+
437
+ @generate_swagger_docs()
438
+ @analytic_api.route("/<id>/comments/<comment_id>/react", methods=["DELETE"])
439
+ @api_login(audit=False, required_priv=["W"])
440
+ def remove_react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
441
+ """React to a comment
442
+
443
+ Variables:
444
+ id => id of the analytic the comment belongs to
445
+ comment_id => id of the comment we are editing
446
+
447
+ Optional Arguments:
448
+ None
449
+
450
+ Result Example:
451
+ {
452
+ ...analytic # The new data for the analytic
453
+ }
454
+ """
455
+ if not analytic_service.does_analytic_exist(id):
456
+ return not_found(err=f"Analytic {id} does not exist")
457
+
458
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
459
+
460
+ for comment in analytic.comment:
461
+ if comment.id == comment_id:
462
+ reactions = comment.get("reactions", {})
463
+ reactions.pop(user["uname"], None)
464
+ comment["reactions"] = {**reactions}
465
+
466
+ datastore().analytic.save(analytic.analytic_id, analytic)
467
+
468
+ return ok(analytic)
469
+
470
+
471
+ @generate_swagger_docs()
472
+ @analytic_api.route("/<id>/comments", methods=["DELETE"])
473
+ @api_login(audit=False, required_priv=["W"])
474
+ def delete_comments(id: str, user: User, **kwargs):
475
+ """Delete a set of comments
476
+
477
+ Variables:
478
+ id => id of the analytic whose comments we are deleting
479
+
480
+ Optional Arguments:
481
+ None
482
+
483
+ Data Block:
484
+ [
485
+ ...comment_ids
486
+ ]
487
+
488
+ Result Example:
489
+ {
490
+ }
491
+ """
492
+ if not analytic_service.does_analytic_exist(id):
493
+ return not_found(err=f"Analytic {id} does not exist")
494
+
495
+ comment_ids: list[str] = request.json or []
496
+
497
+ if len(comment_ids) == 0:
498
+ return bad_request(err="Supply at least one comment to delete.")
499
+
500
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
501
+
502
+ new_comments = []
503
+ for comment in analytic.comment:
504
+ if comment.id in comment_ids:
505
+ if ("admin" not in user["type"]) and comment.user != user["uname"]:
506
+ return forbidden(err="You cannot delete the comment of someone else.")
507
+
508
+ continue
509
+
510
+ new_comments.append(comment)
511
+
512
+ analytic.comment = new_comments
513
+
514
+ try:
515
+ datastore().analytic.save(analytic.analytic_id, analytic)
516
+ except DataStoreException as e:
517
+ return bad_request(err=str(e))
518
+
519
+ return no_content()
520
+
521
+
522
+ @generate_swagger_docs()
523
+ @analytic_api.route("/<id>/owner", methods=["POST"])
524
+ @api_login(required_priv=["W"])
525
+ def set_analytic_owner(id: str, user: dict[str, Any], **kwargs):
526
+ """Set the analytic's owner
527
+
528
+ Variables:
529
+ id => id of the analytic to claim
530
+
531
+ Arguments:
532
+ None
533
+
534
+ Data Block:
535
+ {
536
+ "username": "admin" # The username to set the owner as
537
+ }
538
+
539
+ Result Example:
540
+ {
541
+ ...analytic # The claimed analytic
542
+ }
543
+ """
544
+ if not analytic_service.does_analytic_exist(id):
545
+ return not_found(err=f"Analytic {id} does not exist")
546
+
547
+ data: dict[str, Any] = typing.cast(dict[str, Any], request.json)
548
+ if not user_service.get_user(data["username"]):
549
+ return not_found(err=f"User {data['username']} does not exist")
550
+
551
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
552
+
553
+ analytic.owner = data["username"]
554
+
555
+ ds = datastore()
556
+ ds.analytic.save(analytic.analytic_id, analytic)
557
+ ds.analytic.commit()
558
+
559
+ return ok(analytic)
560
+
561
+
562
+ @generate_swagger_docs()
563
+ @analytic_api.route("/<id>/favourite", methods=["POST"])
564
+ @api_login(required_priv=["R", "W"])
565
+ def set_as_favourite(id, **kwargs):
566
+ """Add an analytic to a list of the user's favourites
567
+
568
+ Variables:
569
+ id => The id of the analytic to add as a favourite
570
+
571
+ Optional Arguments:
572
+ None
573
+
574
+ Data Block:
575
+ {} # Empty
576
+
577
+ Result Example:
578
+ {
579
+ "success": True # If the operation succeeded
580
+ }
581
+ """
582
+ storage = datastore()
583
+
584
+ existing_analytic: Analytic = storage.analytic.get_if_exists(id)
585
+ if not existing_analytic:
586
+ return not_found(err="This analytic does not exist")
587
+
588
+ try:
589
+ current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
590
+
591
+ current_user["favourite_analytics"] = list(set(current_user.get("favourite_analytics", []) + [id]))
592
+
593
+ storage.user.save(current_user["uname"], current_user)
594
+
595
+ return ok()
596
+ except ValueError as e:
597
+ return bad_request(err=str(e))
598
+
599
+
600
+ @generate_swagger_docs()
601
+ @analytic_api.route("/<id>/favourite", methods=["DELETE"])
602
+ @api_login(required_priv=["R", "W"])
603
+ def remove_as_favourite(id, **kwargs):
604
+ """Remove an analytic from a list of the user's favourites
605
+
606
+ Variables:
607
+ id => The id of the analytic to remove as a favourite
608
+
609
+ Optional Arguments:
610
+ None
611
+
612
+ Result Example:
613
+ {
614
+ "success": True # If the operation succeeded
615
+ }
616
+ """
617
+ storage = datastore()
618
+
619
+ if not storage.analytic.exists(id):
620
+ return not_found(err="This analytic does not exist")
621
+
622
+ try:
623
+ current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
624
+
625
+ current_user["favourite_analytics"] = list(
626
+ filter(lambda f: f != id, current_user.get("favourite_analytics", []))
627
+ )
628
+
629
+ storage.user.save(current_user["uname"], current_user)
630
+
631
+ return no_content()
632
+ except ValueError as e:
633
+ return bad_request(err=str(e))
634
+
635
+
636
+ @generate_swagger_docs()
637
+ @analytic_api.route("/<id>/notebooks", methods=["POST"])
638
+ @api_login(audit=False, required_priv=["W"])
639
+ def add_notebook(id: str, user: dict[str, Any], **kwargs):
640
+ """Add a notebook
641
+
642
+ Variables:
643
+ id => id of the analytic to add a notebook to
644
+
645
+ Optional Arguments:
646
+ None
647
+
648
+ Data Block:
649
+ {
650
+ value: "New notebook link",
651
+ name: "New notebook name",
652
+ detection: "Detection to add a notebook about (optional)",
653
+ }
654
+
655
+ Result Example:
656
+ {
657
+ ...analytic # The new data for the analytic
658
+ }
659
+ """
660
+ data = request.json
661
+ if not isinstance(data, dict):
662
+ return bad_request(err="Incorrect data format")
663
+
664
+ link = data.get("value")
665
+ name = data.get("name")
666
+ detection = data.get("detection", None)
667
+
668
+ if not link or not name:
669
+ return bad_request(err="Value/Name cannot be empty.")
670
+
671
+ if "nbgallery." not in link:
672
+ return bad_request(err="Only nbgallery is supported for now.")
673
+
674
+ if not analytic_service.does_analytic_exist(id):
675
+ return not_found(err="Analytic %s does not exist" % id)
676
+
677
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
678
+
679
+ try:
680
+ analytic.notebooks.append(
681
+ Notebook(
682
+ {
683
+ "user": user["uname"],
684
+ "name": name,
685
+ "value": link,
686
+ "detection": detection if detection else None,
687
+ }
688
+ )
689
+ )
690
+
691
+ datastore().analytic.save(analytic.analytic_id, analytic)
692
+ except DataStoreException as e:
693
+ return bad_request(err=str(e))
694
+
695
+ analytic = analytic_service.get_analytic(id)
696
+
697
+ return ok(analytic)
698
+
699
+
700
+ @generate_swagger_docs()
701
+ @analytic_api.route("/<id>/notebooks", methods=["DELETE"])
702
+ @api_login(audit=False, required_priv=["W"])
703
+ def delete_notebook(id: str, user: User, **kwargs):
704
+ """Delete a notebook
705
+
706
+ Variables:
707
+ id => id of the analytic whose notebook we are deleting
708
+
709
+ Optional Arguments:
710
+ None
711
+
712
+ Data Block:
713
+ [
714
+ notebook_id
715
+ ]
716
+
717
+ Result Example:
718
+ {
719
+ }
720
+ """
721
+ if not analytic_service.does_analytic_exist(id):
722
+ return not_found(err=f"Analytic {id} does not exist")
723
+
724
+ notebook_ids: list[str] = request.json or []
725
+
726
+ if len(notebook_ids) == 0:
727
+ return bad_request(err="A notebook id is necessary for deletion.")
728
+
729
+ analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
730
+
731
+ new_notebooks = []
732
+ for notebook in analytic.notebooks:
733
+ if notebook.id in notebook_ids:
734
+ if ("admin" not in user["type"]) and notebook.user != user["uname"]:
735
+ return forbidden(err="You cannot delete the notebook of someone else.")
736
+
737
+ continue
738
+
739
+ new_notebooks.append(notebook)
740
+
741
+ analytic.notebooks = new_notebooks
742
+
743
+ try:
744
+ datastore().analytic.save(analytic.analytic_id, analytic)
745
+ except DataStoreException as e:
746
+ return bad_request(err=str(e))
747
+
748
+ return no_content()