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,893 @@
1
+ import functools
2
+ import json
3
+ import re
4
+ import typing
5
+ from hashlib import sha256
6
+ from typing import Any, Literal, Optional, Union, cast
7
+
8
+ from prometheus_client import Counter
9
+
10
+ import howler.services.event_service as event_service
11
+ from howler.actions.promote import Escalation
12
+ from howler.common.exceptions import HowlerTypeError, HowlerValueError, NotFoundException, ResourceExists
13
+ from howler.common.loader import APP_NAME, datastore
14
+ from howler.common.logging import get_logger
15
+ from howler.datastore.collection import ESCollection
16
+ from howler.datastore.operations import OdmHelper, OdmUpdateOperation
17
+ from howler.datastore.types import HitSearchResult
18
+ from howler.helper.hit import (
19
+ AssessmentEscalationMap,
20
+ assess_hit,
21
+ assign_hit,
22
+ check_ownership,
23
+ demote_hit,
24
+ promote_hit,
25
+ unassign_hit,
26
+ vote_hit,
27
+ )
28
+ from howler.helper.workflow import Transition, Workflow
29
+ from howler.odm.models.ecs.event import Event
30
+ from howler.odm.models.hit import Hit
31
+ from howler.odm.models.howler_data import HitOperationType, HitStatus, HitStatusTransition, Log
32
+ from howler.odm.models.user import User
33
+ from howler.services import action_service, analytic_service, dossier_service, overview_service, template_service
34
+ from howler.utils.dict_utils import extra_keys, flatten
35
+ from howler.utils.uid import get_random_id
36
+
37
+ logger = get_logger(__file__)
38
+
39
+ odm_helper = OdmHelper(Hit)
40
+
41
+
42
+ def get_hit_workflow() -> Workflow:
43
+ """Get the workflow that is used for transitioning between howler statuses
44
+
45
+ Returns:
46
+ Workflow: The workflow used to manage hit status transitions
47
+ """
48
+ return Workflow(
49
+ "howler.status",
50
+ [
51
+ Transition(
52
+ {
53
+ # current user starts investigation
54
+ "source": HitStatus.OPEN,
55
+ "transition": HitStatusTransition.ASSIGN_TO_ME,
56
+ "dest": HitStatus.IN_PROGRESS,
57
+ "actions": [assign_hit],
58
+ }
59
+ ),
60
+ Transition(
61
+ {
62
+ # assign to other user and starts investigation
63
+ "source": HitStatus.OPEN,
64
+ "transition": HitStatusTransition.ASSIGN_TO_OTHER,
65
+ "dest": HitStatus.OPEN,
66
+ "actions": [assign_hit],
67
+ }
68
+ ),
69
+ Transition(
70
+ {
71
+ # assign to other user and starts investigation
72
+ "source": HitStatus.OPEN,
73
+ "transition": HitStatusTransition.START,
74
+ "dest": HitStatus.IN_PROGRESS,
75
+ "actions": [check_ownership],
76
+ }
77
+ ),
78
+ Transition(
79
+ {
80
+ # provides vote
81
+ "source": HitStatus.OPEN,
82
+ "transition": HitStatusTransition.VOTE,
83
+ "dest": HitStatus.OPEN,
84
+ "actions": [vote_hit],
85
+ }
86
+ ),
87
+ Transition(
88
+ {
89
+ # assign to another user
90
+ "source": HitStatus.IN_PROGRESS,
91
+ "transition": HitStatusTransition.ASSIGN_TO_OTHER,
92
+ "dest": HitStatus.IN_PROGRESS,
93
+ "actions": [assign_hit],
94
+ }
95
+ ),
96
+ Transition(
97
+ {
98
+ # removes assignment
99
+ "source": HitStatus.IN_PROGRESS,
100
+ "transition": HitStatusTransition.RELEASE,
101
+ "dest": HitStatus.OPEN,
102
+ "actions": [unassign_hit],
103
+ }
104
+ ),
105
+ Transition(
106
+ {
107
+ # user completes investigation
108
+ "source": [HitStatus.OPEN, HitStatus.IN_PROGRESS],
109
+ "transition": HitStatusTransition.ASSESS,
110
+ "dest": HitStatus.RESOLVED,
111
+ "actions": [assess_hit, assign_hit],
112
+ }
113
+ ),
114
+ Transition(
115
+ {
116
+ # vote on in_progress hit
117
+ "source": HitStatus.IN_PROGRESS,
118
+ "transition": HitStatusTransition.VOTE,
119
+ "dest": HitStatus.IN_PROGRESS,
120
+ "actions": [vote_hit],
121
+ }
122
+ ),
123
+ Transition(
124
+ {
125
+ # removes assignment
126
+ "source": HitStatus.OPEN,
127
+ "transition": HitStatusTransition.RELEASE,
128
+ "dest": HitStatus.OPEN,
129
+ "actions": [unassign_hit],
130
+ }
131
+ ),
132
+ Transition(
133
+ {
134
+ # user pauses investigation
135
+ "source": HitStatus.IN_PROGRESS,
136
+ "transition": HitStatusTransition.PAUSE,
137
+ "dest": HitStatus.ON_HOLD,
138
+ "actions": [check_ownership],
139
+ }
140
+ ),
141
+ Transition(
142
+ {
143
+ # user restarts investigation after pausing it
144
+ "source": HitStatus.ON_HOLD,
145
+ "transition": HitStatusTransition.RESUME,
146
+ "dest": HitStatus.IN_PROGRESS,
147
+ "actions": [check_ownership],
148
+ }
149
+ ),
150
+ Transition(
151
+ {
152
+ # current user starts investigation
153
+ "transition": HitStatusTransition.ASSIGN_TO_ME,
154
+ "source": HitStatus.IN_PROGRESS,
155
+ "dest": HitStatus.IN_PROGRESS,
156
+ "actions": [assign_hit],
157
+ }
158
+ ),
159
+ Transition(
160
+ {
161
+ # user restarts investigation after pausing it
162
+ "source": HitStatus.ON_HOLD,
163
+ "transition": HitStatusTransition.ASSIGN_TO_OTHER,
164
+ "dest": HitStatus.IN_PROGRESS,
165
+ "actions": [assign_hit],
166
+ }
167
+ ),
168
+ Transition(
169
+ {
170
+ # user restarts investigation after pausing it
171
+ "transition": HitStatusTransition.VOTE,
172
+ "source": HitStatus.ON_HOLD,
173
+ "dest": HitStatus.ON_HOLD,
174
+ "actions": [vote_hit],
175
+ }
176
+ ),
177
+ Transition(
178
+ {
179
+ # Reopen a task after resolving it
180
+ "source": HitStatus.RESOLVED,
181
+ "transition": HitStatusTransition.RE_EVALUATE,
182
+ "dest": HitStatus.IN_PROGRESS,
183
+ "actions": [assess_hit, assign_hit],
184
+ }
185
+ ),
186
+ Transition(
187
+ {
188
+ # Reopen a task after resolving it
189
+ "source": HitStatus.RESOLVED,
190
+ "transition": HitStatusTransition.VOTE,
191
+ "dest": HitStatus.RESOLVED,
192
+ "actions": [vote_hit],
193
+ }
194
+ ),
195
+ Transition(
196
+ {
197
+ "source": None,
198
+ "transition": HitStatusTransition.PROMOTE,
199
+ "dest": None,
200
+ "actions": [promote_hit],
201
+ }
202
+ ),
203
+ Transition(
204
+ {
205
+ "source": None,
206
+ "transition": HitStatusTransition.DEMOTE,
207
+ "actions": [demote_hit],
208
+ "dest": None,
209
+ }
210
+ ),
211
+ ],
212
+ )
213
+
214
+
215
+ def _modifies_prop(prop: str, operations: list[OdmUpdateOperation]) -> bool:
216
+ """Check if the list of provided operations modifies the specified property
217
+
218
+ Args:
219
+ prop (str): The property to check for changes
220
+ operations (list[OdmUpdateOperation]): The operations that will be performed
221
+
222
+ Returns:
223
+ bool: Is the property modified by these operations?
224
+ """
225
+ return any(op for op in operations if op.key == prop)
226
+
227
+
228
+ def does_hit_exist(hit_id: str) -> bool:
229
+ """Checks if the provided ID matches any entries in the database
230
+
231
+ Args:
232
+ hit_id (str): The ID to check for in the database
233
+
234
+ Returns:
235
+ bool: Does the ID match a document in the database?
236
+ """
237
+ return datastore().hit.exists(hit_id)
238
+
239
+
240
+ def validate_hit_ids(hit_ids: list[str]) -> bool:
241
+ """Checks if all hit_ids are available
242
+
243
+ Args:
244
+ hit_ids (list[str]): A list of hit ids to validate
245
+
246
+ Returns:
247
+ bool: Whether all of the hit ids are free to use
248
+ """
249
+ return not any(does_hit_exist(hit_id) for hit_id in hit_ids)
250
+
251
+
252
+ def convert_hit(data: dict[str, Any], unique: bool, ignore_extra_values: bool = False) -> tuple[Hit, list[str]]: # noqa: C901
253
+ """Validate and convert a dictionary to a Hit ODM object.
254
+
255
+ This function performs comprehensive validation on input data to ensure it can be
256
+ safely converted to a Hit object. It handles hash generation, ID assignment,
257
+ data normalization, and validation warnings. The function also checks for
258
+ deprecated fields and enforces naming conventions for analytics and detections.
259
+
260
+ Args:
261
+ data: Dictionary containing hit data to validate and convert
262
+ unique: Whether to enforce uniqueness by checking if the hit ID already exists
263
+ ignore_extra_values: Whether to ignore invalid extra fields (True) or raise an exception (False)
264
+
265
+ Returns:
266
+ Tuple containing:
267
+ - Hit: The validated and converted ODM object
268
+ - list[str]: List of validation warnings (unused fields, deprecated fields, naming issues)
269
+
270
+ Raises:
271
+ HowlerValueError: If bundle is specified during creation, invalid parameters are provided,
272
+ or naming conventions are violated
273
+ HowlerTypeError: If the data cannot be converted to a Hit ODM object
274
+ ResourceExists: If unique=True and a hit with the generated ID already exists
275
+
276
+ Note:
277
+ - Automatically generates a hash based on analytic, detection, and raw data
278
+ - Assigns a random ID if not provided
279
+ - Normalizes data fields to ensure consistent storage format
280
+ - Validates analytic and detection names against best practices (letters and spaces only)
281
+ """
282
+ data = flatten(data, odm=Hit)
283
+
284
+ if "howler.hash" not in data:
285
+ hash_contents = {
286
+ "analytic": data.get("howler.analytic", "no_analytic"),
287
+ "detection": data.get("howler.detection", "no_detection"),
288
+ "raw_data": data.get("howler.data", {}),
289
+ }
290
+
291
+ data["howler.hash"] = sha256(
292
+ json.dumps(hash_contents, sort_keys=True, ensure_ascii=True).encode("utf-8")
293
+ ).hexdigest()
294
+
295
+ data["howler.id"] = get_random_id()
296
+
297
+ if "howler.bundles" in data and len(data["howler.bundles"]) > 0:
298
+ raise HowlerValueError("You cannot specify a bundle when creating a hit.")
299
+
300
+ if "howler.data" in data:
301
+ parsed_data = []
302
+ for entry in data["howler.data"]:
303
+ if isinstance(entry, str):
304
+ parsed_data.append(entry)
305
+ else:
306
+ parsed_data.append(json.dumps(entry))
307
+
308
+ data["howler.data"] = parsed_data
309
+
310
+ if "bundle_size" not in data and "howler.hits" in data:
311
+ data["howler.bundle_size"] = len(data["howler.hits"])
312
+
313
+ # TODO: This is a really strange double-validation check we should look to refactor
314
+ try:
315
+ odm = Hit(data, ignore_extra_values=ignore_extra_values)
316
+ except TypeError as e:
317
+ raise HowlerTypeError(str(e), cause=e) from e
318
+
319
+ # Check for deprecated field and unused fields
320
+ odm_flatten = odm.flat_fields(show_compound=True)
321
+ unused_keys = extra_keys(Hit, data)
322
+
323
+ if unused_keys and not ignore_extra_values:
324
+ raise HowlerValueError(f"Hit was created with invalid parameters: {', '.join(unused_keys)}")
325
+ deprecated_keys = set(key for key in odm_flatten.keys() & data.keys() if odm_flatten[key].deprecated)
326
+
327
+ warnings = [f"{key} is not currently used by howler." for key in unused_keys]
328
+ warnings.extend(
329
+ [f"{key} is deprecated." for key in deprecated_keys],
330
+ )
331
+
332
+ if re.search(r"^([A-Za-z ])+$", odm.howler.analytic) is None:
333
+ warnings.append(
334
+ f"The value {odm.howler.analytic} does not match best practices for Howler analytic names. "
335
+ "See howler's documentation for more information."
336
+ )
337
+
338
+ if odm.howler.detection and re.search(r"^([A-Za-z ])+$", odm.howler.detection) is None:
339
+ warnings.append(
340
+ f"The value {odm.howler.detection} does not match best practices for Howler detection names. "
341
+ "See howler's documentation for more information."
342
+ )
343
+
344
+ if odm.event:
345
+ odm.event.id = odm.howler.id
346
+ if not odm.event.created:
347
+ odm.event.created = "NOW"
348
+ else:
349
+ odm.event = Event({"created": "NOW", "id": odm.howler.id})
350
+
351
+ if unique and does_hit_exist(odm.howler.id):
352
+ raise ResourceExists("Resource with id %s already exists" % odm.howler.id)
353
+
354
+ return odm, warnings
355
+
356
+
357
+ def exists(id: str):
358
+ """Check if a hit exists in the datastore.
359
+
360
+ Args:
361
+ id: The unique identifier of the hit to check
362
+
363
+ Returns:
364
+ bool: True if the hit exists, False otherwise
365
+ """
366
+ return datastore().hit.exists(id)
367
+
368
+
369
+ def get_hit(
370
+ id: str,
371
+ as_odm: bool = False,
372
+ version: bool = False,
373
+ ):
374
+ """Retrieve a hit from the datastore.
375
+
376
+ Args:
377
+ id: The unique identifier of the hit to retrieve
378
+ as_odm: Whether to return the hit as an ODM object (True) or dictionary (False)
379
+ version: Whether to include version information in the response
380
+
381
+ Returns:
382
+ Hit object (if as_odm=True) or dictionary representation of the hit.
383
+ Returns None if the hit doesn't exist.
384
+ """
385
+ return datastore().hit.get_if_exists(key=id, as_obj=as_odm, version=version)
386
+
387
+
388
+ CREATED_HITS = Counter(
389
+ f"{APP_NAME.replace('-', '_')}_created_hits_total",
390
+ "The number of created hits",
391
+ ["analytic"],
392
+ )
393
+
394
+
395
+ def create_hit(
396
+ id: str,
397
+ hit: Hit,
398
+ user: Optional[str] = None,
399
+ overwrite: bool = False,
400
+ ) -> bool:
401
+ """Create a new hit in the database.
402
+
403
+ This function saves a hit to the datastore, optionally adding a creation log entry
404
+ and updating metrics. It will prevent overwriting existing hits unless explicitly allowed.
405
+
406
+ Args:
407
+ id: The unique identifier for the hit
408
+ hit: The Hit ODM object to save
409
+ user: Optional username to record in the creation log
410
+ overwrite: Whether to allow overwriting an existing hit with the same ID
411
+
412
+ Returns:
413
+ bool: True if the hit was successfully created
414
+
415
+ Raises:
416
+ ResourceExists: If a hit with the same ID already exists and overwrite=False
417
+ """
418
+ if not overwrite and does_hit_exist(id):
419
+ raise ResourceExists("Hit %s already exists in datastore" % id)
420
+
421
+ if user:
422
+ hit.howler.log = [Log({"timestamp": "NOW", "explanation": "Created hit", "user": user})]
423
+
424
+ CREATED_HITS.labels(hit.howler.analytic).inc()
425
+ return datastore().hit.save(id, hit)
426
+
427
+
428
+ def update_hit(
429
+ hit_id: str,
430
+ operations: list[OdmUpdateOperation],
431
+ user: Optional[str] = None,
432
+ version: Optional[str] = None,
433
+ ):
434
+ """Update one or more properties of a hit in the database.
435
+
436
+ This function applies a list of update operations to modify hit properties.
437
+ Note that hit status cannot be modified through this function - use transition_hit instead.
438
+
439
+ Args:
440
+ hit_id: The unique identifier of the hit to update
441
+ operations: List of ODM update operations to apply
442
+ user: Optional username to record in the update log
443
+ version: Optional version string for optimistic locking
444
+
445
+ Returns:
446
+ Tuple of (updated_hit_data, new_version)
447
+
448
+ Raises:
449
+ HowlerValueError: If attempting to modify hit status through this function
450
+ """
451
+ # Status of a hit should only be updated through the transition function
452
+ if _modifies_prop("howler.status", operations):
453
+ raise HowlerValueError(
454
+ "Status of a Hit cannot be modified like other properties. Please use a transition to do so."
455
+ )
456
+
457
+ return _update_hit(hit_id, operations, user, version=version)
458
+
459
+
460
+ @typing.no_type_check
461
+ def save_hit(hit: Hit, version: Optional[str] = None) -> tuple[Hit, str]:
462
+ """Save a hit to the datastore and emit an event notification.
463
+
464
+ This function persists a hit object to the database and emits an event
465
+ to notify other systems of the change.
466
+
467
+ Args:
468
+ hit: The Hit ODM object to save
469
+ version: Optional version string for optimistic locking
470
+
471
+ Returns:
472
+ Tuple of (hit_data_dict, version_string)
473
+ """
474
+ datastore().hit.save(hit.howler.id, hit, version=version)
475
+ data, _version = datastore().hit.get(hit.howler.id, as_obj=False, version=True)
476
+ event_service.emit("hits", {"hit": data, "version": _version})
477
+
478
+ return data, version
479
+
480
+
481
+ def _update_hit(
482
+ hit_id: str,
483
+ operations: list[OdmUpdateOperation],
484
+ user: Optional[str] = None,
485
+ version: Optional[str] = None,
486
+ ) -> tuple[Hit, str]:
487
+ """Internal function to update a hit with proper logging and event emission.
488
+
489
+ This function applies update operations to a hit, automatically adding worklog entries
490
+ for non-silent operations and emitting events to notify other systems of changes.
491
+
492
+ Args:
493
+ hit_id: The unique identifier of the hit to update
494
+ operations: List of ODM update operations to apply
495
+ user: Optional username to record in operation logs
496
+ version: Optional version string for optimistic locking
497
+
498
+ Returns:
499
+ Tuple of (updated_hit_data, new_version)
500
+
501
+ Raises:
502
+ HowlerValueError: If user parameter is provided but not a string
503
+ """
504
+ final_operations = []
505
+
506
+ if user and not isinstance(user, str):
507
+ raise HowlerValueError("User must be of type string")
508
+
509
+ current_hit = get_hit(hit_id, as_odm=True)
510
+
511
+ for operation in operations:
512
+ if not operation:
513
+ continue
514
+
515
+ try:
516
+ is_list = current_hit.flat_fields()[operation.key].multivalued
517
+ try:
518
+ previous_value = current_hit[operation.key]
519
+ except (TypeError, KeyError):
520
+ previous_value = None
521
+ except KeyError:
522
+ key = next(key for key in current_hit.flat_fields().keys() if key.startswith(operation.key))
523
+ is_list = current_hit.flat_fields()[key].multivalued
524
+ previous_value = "list"
525
+
526
+ operation_type = ""
527
+ if is_list:
528
+ if operation.operation in (
529
+ ESCollection.UPDATE_APPEND,
530
+ ESCollection.UPDATE_APPEND_IF_MISSING,
531
+ ):
532
+ operation_type = HitOperationType.APPENDED
533
+ else:
534
+ operation_type = HitOperationType.REMOVED
535
+ else:
536
+ operation_type = HitOperationType.SET
537
+
538
+ logger.debug("%s - %s - %s -> %s", hit_id, operation.key, previous_value, operation.value)
539
+ final_operations.append(operation)
540
+
541
+ if not operation.silent:
542
+ final_operations.append(
543
+ OdmUpdateOperation(
544
+ ESCollection.UPDATE_APPEND,
545
+ "howler.log",
546
+ {
547
+ "timestamp": "NOW",
548
+ "previous_version": version,
549
+ "key": operation.key,
550
+ "explanation": operation.explanation,
551
+ "new_value": operation.value or "None",
552
+ "previous_value": previous_value or "None",
553
+ "type": operation_type,
554
+ "user": user if user else "Unknown",
555
+ },
556
+ )
557
+ )
558
+
559
+ datastore().hit.update(hit_id, final_operations, version)
560
+ # Need to fetch the new data of the hit for the event_service
561
+ data, _version = datastore().hit.get(hit_id, as_obj=False, version=True)
562
+ event_service.emit("hits", {"hit": data, "version": _version})
563
+
564
+ return data, _version
565
+
566
+
567
+ def get_transitions(status: HitStatus) -> list[str]:
568
+ """Get a list of the valid transitions beginning from the specified status
569
+
570
+ Args:
571
+ status (HitStatus): The status we want to transition from
572
+
573
+ Returns:
574
+ list[str]: A list of valid transitions to execute
575
+ """
576
+ return get_hit_workflow().get_transitions(status)
577
+
578
+
579
+ def get_all_children(hit: Hit):
580
+ """Get a list of all child hits for a given hit, including nested children.
581
+
582
+ This function recursively traverses bundle structures to find all child hits.
583
+ If a child hit is itself a bundle, it will recursively get its children too.
584
+
585
+ Args:
586
+ hit: The parent hit to get children for
587
+
588
+ Returns:
589
+ List of all child hits (may include None values for missing hits)
590
+ """
591
+ # Get immediate child hits from the hit's bundle
592
+ child_hits = [get_hit(hit_id) for hit_id in hit["howler"].get("hits", [])]
593
+
594
+ # Recursively process child hits that are themselves bundles
595
+ for entry in child_hits:
596
+ if not entry:
597
+ continue
598
+
599
+ # If this child is a bundle, get its children too
600
+ if entry["howler"]["is_bundle"]:
601
+ child_hits.extend(get_all_children(entry))
602
+
603
+ return child_hits
604
+
605
+
606
+ def transition_hit(
607
+ id: str,
608
+ transition: HitStatusTransition,
609
+ user: User,
610
+ version: Optional[str] = None,
611
+ **kwargs,
612
+ ):
613
+ """Transition a hit from one status to another while updating related properties.
614
+
615
+ This function handles status transitions for both individual hits and bundles,
616
+ applying the same transition to all child hits in a bundle. For certain transitions
617
+ (PROMOTE, DEMOTE, ASSESS, RE_EVALUATE), it also executes bulk actions and emits events.
618
+
619
+ Args:
620
+ id: The id of the hit to transition
621
+ transition: The transition to execute (e.g., ASSIGN_TO_ME, ASSESS, PROMOTE)
622
+ user: The user running the transition
623
+ version: Optional version to validate against. The transition will not run if the version doesn't match.
624
+ **kwargs: Additional arguments including potential 'hit' object and 'assessment' value
625
+
626
+ Raises:
627
+ NotFoundException: If the hit does not exist
628
+ """
629
+ # Get the primary hit (either provided in kwargs or fetch from database)
630
+ primary_hit: Hit = kwargs.pop("hit", None) or get_hit(id, as_odm=False)
631
+
632
+ if not primary_hit:
633
+ raise NotFoundException("Hit does not exist")
634
+
635
+ workflow: Workflow = get_hit_workflow()
636
+
637
+ # Get all child hits that need to be processed along with the primary hit
638
+ child_hits = get_all_children(primary_hit)
639
+ primary_hit_status = primary_hit["howler"]["status"]
640
+
641
+ # Log all hits that will be transitioned
642
+ all_hit_ids = [h["howler"]["id"] for h in ([primary_hit] + [ch for ch in child_hits if ch])]
643
+ logger.debug("Transitioning (%s)", ", ".join(all_hit_ids))
644
+
645
+ # Process each hit (primary + children) with the workflow transition
646
+ for current_hit in [primary_hit] + [ch for ch in child_hits if ch]:
647
+ current_hit_status = current_hit["howler"]["status"]
648
+ current_hit_id = current_hit["howler"]["id"]
649
+
650
+ # Skip hits that don't match the primary hit's status
651
+ # This ensures consistent state transitions across bundles
652
+ if current_hit_status != primary_hit_status:
653
+ logger.debug("Skipping %s (status mismatch)", current_hit_id)
654
+ continue
655
+
656
+ # Apply the workflow transition to get required updates
657
+ updates = workflow.transition(current_hit_status, transition, user=user, hit=current_hit, **kwargs)
658
+
659
+ # Apply updates if any were generated by the workflow
660
+ if updates:
661
+ # Only apply version validation to the primary hit
662
+ hit_version = version if (current_hit_id == primary_hit["howler"]["id"] and version) else None
663
+ _update_hit(current_hit_id, updates, user["uname"], version=hit_version)
664
+
665
+ # Execute bulk actions for transitions that require them
666
+ # These transitions need additional processing beyond the workflow
667
+ transitions_requiring_bulk_actions = [
668
+ HitStatusTransition.PROMOTE,
669
+ HitStatusTransition.DEMOTE,
670
+ HitStatusTransition.ASSESS,
671
+ HitStatusTransition.RE_EVALUATE,
672
+ ]
673
+
674
+ if transition in transitions_requiring_bulk_actions:
675
+ # Determine the trigger action (promote/demote) based on transition type
676
+ trigger: Union[Literal["promote"], Literal["demote"]]
677
+
678
+ if transition == HitStatusTransition.ASSESS:
679
+ # For assessments, determine promotion/demotion based on escalation level
680
+ new_escalation = AssessmentEscalationMap[kwargs["assessment"]]
681
+ trigger = "promote" if new_escalation == Escalation.EVIDENCE else "demote"
682
+ elif transition == HitStatusTransition.RE_EVALUATE:
683
+ # Re-evaluation always promotes the hit
684
+ trigger = "promote"
685
+ else:
686
+ # For direct PROMOTE/DEMOTE transitions, use the transition name
687
+ trigger = cast(Union[Literal["promote"], Literal["demote"]], transition)
688
+
689
+ # Commit database changes before executing bulk actions
690
+ datastore().hit.commit()
691
+
692
+ # Build query for all processed hits (primary + children)
693
+ all_processed_hits = [primary_hit] + child_hits
694
+ hit_query = f"howler.id:({' OR '.join(h['howler']['id'] for h in all_processed_hits)})"
695
+
696
+ # Execute bulk actions on all hits
697
+ action_service.bulk_execute_on_query(hit_query, trigger=trigger, user=user)
698
+
699
+ # Emit events for all processed hits to notify other systems
700
+ for processed_hit in all_processed_hits:
701
+ data, hit_version = datastore().hit.get(processed_hit["howler"]["id"], as_obj=False, version=True)
702
+ event_service.emit("hits", {"hit": data, "version": hit_version})
703
+
704
+
705
+ DELETED_HITS = Counter(f"{APP_NAME.replace('-', '_')}_deleted_hits_total", "The number of deleted hits")
706
+
707
+
708
+ def delete_hits(hit_ids: list[str]) -> bool:
709
+ """Delete a set of hits from the database
710
+
711
+ Args:
712
+ hit_ids (list[str]): The IDs of the hits to delete
713
+
714
+ Returns:
715
+ bool: Was the deletion successful?
716
+ """
717
+ ds = datastore()
718
+
719
+ operations = []
720
+ result = True
721
+
722
+ for hit_id in hit_ids:
723
+ operations.append(odm_helper.list_remove("howler.hits", hit_id, silent=True))
724
+
725
+ result = result and ds.hit.delete(hit_id)
726
+ DELETED_HITS.inc()
727
+
728
+ ds.hit.update_by_query("howler.is_bundle:true", operations)
729
+
730
+ ds.hit.commit()
731
+
732
+ return result
733
+
734
+
735
+ def search(
736
+ query: str,
737
+ offset: int = 0,
738
+ rows: Optional[int] = None,
739
+ sort: Optional[Any] = None,
740
+ fl: Optional[Any] = None,
741
+ timeout: Optional[Any] = None,
742
+ deep_paging_id: Optional[Any] = None,
743
+ track_total_hits: Optional[Any] = None,
744
+ as_obj: bool = True,
745
+ ) -> HitSearchResult:
746
+ """Search for hits in the datastore using a query.
747
+
748
+ This function provides a flexible search interface for finding hits based on
749
+ various criteria. It supports pagination, sorting, field limiting, and other
750
+ advanced search features.
751
+
752
+ Args:
753
+ query: The search query string (supports Lucene syntax)
754
+ offset: Number of results to skip (for pagination)
755
+ rows: Maximum number of results to return
756
+ sort: Sort criteria for the results
757
+ fl: Field list - which fields to include in results
758
+ timeout: Query timeout duration
759
+ deep_paging_id: Identifier for deep pagination
760
+ track_total_hits: Whether to track the total hit count
761
+ as_obj: Whether to return results as ODM objects (True) or dictionaries (False)
762
+
763
+ Returns:
764
+ HitSearchResult containing the matching hits and metadata
765
+ """
766
+ return datastore().hit.search(
767
+ query=query,
768
+ offset=offset,
769
+ rows=rows,
770
+ sort=sort,
771
+ fl=fl,
772
+ timeout=timeout,
773
+ deep_paging_id=deep_paging_id,
774
+ track_total_hits=track_total_hits,
775
+ as_obj=as_obj,
776
+ )
777
+
778
+
779
+ TYPE_PRIORITY = {"personal": 2, "readonly": 1, "global": 0, None: 0}
780
+
781
+
782
+ def __compare_metadata(object_a: dict[str, Any], object_b: dict[str, Any]) -> int:
783
+ # Sort priority:
784
+ # 1. personal > readonly > global
785
+ # 2. detection > !detection
786
+
787
+ if object_a.get("type", None) != object_b.get("type", None):
788
+ return TYPE_PRIORITY[object_b.get("type", None)] - TYPE_PRIORITY[object_a.get("type", None)]
789
+
790
+ if object_a.get("detection", None) and not object_b.get("detection", None):
791
+ return -1
792
+
793
+ if not object_a.get("detection", None) and object_b.get("detection", None):
794
+ return 1
795
+
796
+ return 0
797
+
798
+
799
+ def __match_metadata(candidates: list[dict[str, Any]], hit: dict[str, Any]) -> Optional[dict[str, Any]]:
800
+ matching_candidates: list[dict[str, Any]] = []
801
+
802
+ for candidate in candidates:
803
+ if candidate["analytic"].lower() != hit["howler"]["analytic"].lower():
804
+ continue
805
+
806
+ if not candidate.get("detection", None):
807
+ matching_candidates.append(candidate)
808
+ continue
809
+
810
+ if not hit["howler"].get("detection", None):
811
+ continue
812
+
813
+ if hit["howler"]["detection"].lower() != candidate["detection"].lower():
814
+ continue
815
+
816
+ matching_candidates.append(candidate)
817
+
818
+ if len(matching_candidates) < 1:
819
+ return None
820
+
821
+ return sorted(matching_candidates, key=functools.cmp_to_key(__compare_metadata))[0]
822
+
823
+
824
+ def augment_metadata(data: list[dict[str, Any]] | dict[str, Any], metadata: list[str], user: dict[str, Any]): # noqa: C901
825
+ """Augment hit search results with additional metadata.
826
+
827
+ This function enriches hit data by adding related information such as templates,
828
+ overviews, and matching dossiers. The metadata is added as special fields prefixed
829
+ with double underscores (e.g., __template, __overview, __dossiers).
830
+
831
+ Args:
832
+ data: Hit data - either a single hit dictionary or list of hit dictionaries
833
+ metadata: List of metadata types to include ('template', 'overview', 'dossiers')
834
+ user: User context for determining accessible templates and other user-specific data
835
+
836
+ Note:
837
+ This function modifies the input data in-place, adding metadata fields.
838
+ Templates are filtered based on user permissions (global or owned by user).
839
+ """
840
+ if isinstance(data, list):
841
+ hits = data
842
+ elif data is not None:
843
+ hits = [data]
844
+ else:
845
+ hits = []
846
+
847
+ if len(hits) < 1:
848
+ return
849
+
850
+ logger.debug("Augmenting %s hits with %s", len(hits), ",".join(metadata))
851
+
852
+ if "template" in metadata:
853
+ template_candidates = template_service.get_matching_templates(hits, user["uname"], as_odm=False)
854
+
855
+ logger.debug("\tRetrieved %s matching templates", len(template_candidates))
856
+
857
+ for hit in hits:
858
+ hit["__template"] = __match_metadata(cast(list[dict[str, Any]], template_candidates), hit)
859
+
860
+ if "overview" in metadata:
861
+ overview_candidates = overview_service.get_matching_overviews(hits, as_odm=False)
862
+
863
+ logger.debug("\tRetrieved %s matching overviews", len(overview_candidates))
864
+
865
+ for hit in hits:
866
+ hit["__overview"] = __match_metadata(cast(list[dict[str, Any]], overview_candidates), hit)
867
+
868
+ if "analytic" in metadata:
869
+ matched_analytics = analytic_service.get_matching_analytics(hits)
870
+ logger.debug("\tRetrieved %s matching analytics", len(matched_analytics))
871
+
872
+ for hit in hits:
873
+ matched_analytic = next(
874
+ (
875
+ analytic
876
+ for analytic in matched_analytics
877
+ if analytic.name.lower() == hit["howler"]["analytic"].lower()
878
+ ),
879
+ None,
880
+ )
881
+
882
+ hit["__analytic"] = matched_analytic.as_primitives() if matched_analytic else None
883
+
884
+ if "dossiers" in metadata:
885
+ dossiers: list[dict[str, Any]] = datastore().dossier.search(
886
+ "dossier_id:*",
887
+ as_obj=False,
888
+ # TODO: Eventually implement caching here
889
+ rows=1000,
890
+ )["items"]
891
+
892
+ for hit in hits:
893
+ hit["__dossiers"] = dossier_service.get_matching_dossiers(hit, dossiers)