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,323 @@
1
+ import base64
2
+ import hashlib
3
+ from datetime import datetime
4
+ from typing import Optional, Union
5
+
6
+ import elasticapm
7
+ from flask import request
8
+
9
+ import howler.services.jwt_service as jwt_service
10
+ import howler.services.user_service as user_service
11
+ from howler.common.exceptions import (
12
+ AccessDeniedException,
13
+ AuthenticationException,
14
+ HowlerException,
15
+ InvalidDataException,
16
+ )
17
+ from howler.common.loader import datastore
18
+ from howler.common.logging import get_logger
19
+ from howler.config import config, redis
20
+ from howler.odm.models.user import User
21
+ from howler.remote.datatypes.queues.named import NamedQueue
22
+ from howler.remote.datatypes.set import ExpiringSet
23
+ from howler.security.utils import generate_random_secret, verify_password
24
+
25
+ logger = get_logger(__file__)
26
+
27
+ nonpersistent_config: dict[str, Union[str, int]] = {
28
+ "host": config.core.redis.nonpersistent.host,
29
+ "port": config.core.redis.nonpersistent.port,
30
+ "ttl": config.auth.internal.failure_ttl,
31
+ }
32
+
33
+
34
+ def _get_token_store(user: str) -> ExpiringSet:
35
+ """Get an expiring redis set in which to add a token
36
+
37
+ Args:
38
+ user (str): The user the token corresponds to
39
+
40
+ Returns:
41
+ ExpiringSet: The set in which we'll store the token
42
+ """
43
+ return ExpiringSet(f"token_{user}", host=redis, ttl=60 * 60) # 1 Hour expiry
44
+
45
+
46
+ def _get_priv_store(user: str, token: str) -> ExpiringSet:
47
+ """Get an expiring redis set in which to add the privileges
48
+
49
+ Args:
50
+ user (str): The user the token corresponds to
51
+ token (str): The token the privileges correspond to
52
+
53
+ Returns:
54
+ ExpiringSet: The set in which we'll store the privileges
55
+ """
56
+ return ExpiringSet(
57
+ # For security reasons, we won't save the whole token in redis. Just in case :)
58
+ f"token_priv_{user}_{token[:10]}",
59
+ host=redis,
60
+ # 1 Hour expiry
61
+ ttl=60 * 60,
62
+ )
63
+
64
+
65
+ def create_token(user: str, priv: list[str]) -> str:
66
+ """Generate a new token associated with the given user with the given privileges
67
+
68
+ Args:
69
+ user (str): The user to create the token as
70
+ priv (list[str]): The privileges to give the token
71
+
72
+ Returns:
73
+ str: The new token
74
+ """
75
+ token = hashlib.sha256(str(generate_random_secret()).encode("utf-8", errors="replace")).hexdigest()
76
+
77
+ _get_token_store(user).add(token)
78
+ priv_store = _get_priv_store(user, token)
79
+ priv_store.pop_all()
80
+ priv_store.add(",".join(priv))
81
+
82
+ return token
83
+
84
+
85
+ def check_token(user: str, token: str) -> Optional[list[str]]:
86
+ """Check if a token exists, and return its list of privileges
87
+
88
+ Args:
89
+ user (str): The user corresponding to the token to check
90
+ token (str): The token
91
+
92
+ Returns:
93
+ Optional[list[str]]: The list of privileges associated with the token
94
+ """
95
+ if _get_token_store(user).exist(token):
96
+ members = _get_priv_store(user, token).members()
97
+ if len(members) > 0:
98
+ priv_str = members[0]
99
+ return priv_str.split(",")
100
+
101
+ return None
102
+
103
+
104
+ def validate_token(username: str, token: str) -> Optional[list[str]]:
105
+ """This function identifies the user via the internal token functionality
106
+
107
+ Args:
108
+ username (str): The username corresponding to the provided token
109
+ token (str): The token generated by our API to check for
110
+
111
+ Raises:
112
+ AuthenticationException: Invalid token
113
+
114
+ Returns:
115
+ tuple[Optional[User], Optional[list[str]]]: The user odm object and privileges, if validated
116
+ """
117
+ if token:
118
+ priv = check_token(username, token)
119
+ if priv:
120
+ return priv
121
+
122
+ raise AuthenticationException("Invalid token")
123
+
124
+ return None
125
+
126
+
127
+ @elasticapm.capture_span(span_type="authentication")
128
+ def bearer_auth(
129
+ data: str, skip_jwt: bool = False, skip_internal: bool = False
130
+ ) -> tuple[Optional[User], Optional[list[str]]]:
131
+ """This function handles Bearer type Authorization headers.
132
+
133
+ Args:
134
+ data (str): The corresponding data in the Authorization header.
135
+
136
+ Returns:
137
+ tuple[Optional[User], Optional[list[str]]]: The user odm object and privileges, if validated
138
+ """
139
+ if "." in data:
140
+ if not skip_jwt:
141
+ try:
142
+ jwt_data = jwt_service.decode(data, validate_audience=True)
143
+ except HowlerException as e:
144
+ raise AuthenticationException(
145
+ "Something went wrong when decoding your key. Please reauthenticate.",
146
+ cause=e,
147
+ )
148
+
149
+ cur_user = user_service.parse_user_data(jwt_data, jwt_service.get_provider(data))
150
+
151
+ if cur_user:
152
+ return cur_user, ["R", "W", "E"]
153
+
154
+ return None, None
155
+ else:
156
+ raise InvalidDataException("Not a valid authentication type for this endpoint.")
157
+ else:
158
+ if not skip_internal:
159
+ [username, token] = data.split(":", maxsplit=1)
160
+
161
+ privs = validate_token(username, token)
162
+
163
+ if privs is not None:
164
+ return datastore().user.get(username), privs
165
+
166
+ return None, None
167
+ else:
168
+ raise InvalidDataException("Not a valid authentication type for this endpoint.")
169
+
170
+
171
+ @elasticapm.capture_span(span_type="authentication")
172
+ def validate_apikey(
173
+ username: str, apikey: str, impersonator: Optional[User] = None
174
+ ) -> tuple[Optional[User], Optional[list[str]]]:
175
+ """This function identifies the user via the internal API key functionality.
176
+
177
+ Args:
178
+ username (str): The username corresponding to the provided api key
179
+ apikey (str): The apikey used to authenticate as the user
180
+ impersonator (Optional[str]): The user who wants to impersonate as the provided username. Defaults to None.
181
+
182
+ Raises:
183
+ AccessDeniedException: Api Key authentication was disabled, or the api was not valid for impersonation,
184
+ or it was an impersonation api key incorrectly provided in the Authorization header.
185
+
186
+ Returns:
187
+ tuple[Optional[User], Optional[list[str]]]: The user odm object and privileges, if validated
188
+ """
189
+ if config.auth.allow_apikeys and apikey:
190
+ user_data: User = datastore().user.get_if_exists(username)
191
+ if user_data:
192
+ try:
193
+ # Get the name and secret data of the api key we are validating
194
+ name, apikey_password = apikey.split(":", 1)
195
+ key = user_data.apikeys.get(name, None)
196
+
197
+ # Does the key actually exist?
198
+ if not key:
199
+ raise AuthenticationException("API Key does not exist")
200
+
201
+ if key.expiry_date is not None:
202
+ if key.expiry_date.replace(tzinfo=None) < datetime.utcnow():
203
+ raise AuthenticationException("Key is expired")
204
+
205
+ # Handle impersonation. Basically, make sure that either:
206
+ # a) someone is trying to impersonate as this user, and the apikey can be used for that, AND the
207
+ # impersonator is on the list of people allowed to use it
208
+ # b) The user is not being impersonated, and the api key isn't specifically meant for impersonation
209
+ if impersonator and ("I" not in key.acl or impersonator["uname"] not in key.agents):
210
+ raise AccessDeniedException("Not a valid impersonation api key")
211
+ elif not impersonator and "I" in key.acl:
212
+ raise AccessDeniedException(
213
+ "Cannot use impersonation key in normal Authorization Header! "
214
+ + "Provide your credentials and supply it in the X-Impersonating header instead."
215
+ )
216
+
217
+ # If the key can be used for whichever purpose, actually validate the secret data
218
+ if verify_password(apikey_password, key.password):
219
+ return user_data, key.acl
220
+ except ValueError:
221
+ pass
222
+
223
+ return None, None
224
+ else:
225
+ raise AccessDeniedException("API Key authentication disabled")
226
+
227
+
228
+ def validate_userpass(username: str, password: str) -> tuple[Optional[User], Optional[list[str]]]:
229
+ """This function identifies the user via the user/pass functionality
230
+
231
+ Args:
232
+ username (str): The username corresponding to the provided password
233
+ password (str): The password used to authenticate as the user
234
+
235
+ Raises:
236
+ AccessDeniedException: Username/Password authentication is currently disabled
237
+
238
+ Returns:
239
+ tuple[Optional[User], Optional[list[str]]]: The user odm object and privileges, if validated
240
+ """
241
+ if config.auth.internal.enabled and username and password:
242
+ user = datastore().user.get(username)
243
+ if user:
244
+ if verify_password(password, user.password):
245
+ return user, ["R", "W", "E"]
246
+
247
+ return None, None
248
+ else:
249
+ raise AccessDeniedException("Username/Password authentication disabled")
250
+
251
+
252
+ def decode_b64(b64_str: str) -> str:
253
+ """Decode a base64 string into plain text.
254
+
255
+ Args:
256
+ b64_str (str): The base64 string
257
+
258
+ Raises:
259
+ InvalidDataException: The data was not base64.
260
+
261
+ Returns:
262
+ str: A plain text representation of the data.
263
+ """
264
+ try:
265
+ return base64.b64decode(b64_str).decode("utf-8")
266
+ except UnicodeDecodeError as e:
267
+ raise InvalidDataException("Basic authentication data must be base64 encoded") from e
268
+
269
+
270
+ @elasticapm.capture_span(span_type="authentication")
271
+ def basic_auth(
272
+ data: str, is_base64: bool = True, skip_apikey: bool = False, skip_password: bool = False
273
+ ) -> tuple[Optional[User], Optional[list[str]]]:
274
+ """This function handles Basic type Authorization headers.
275
+
276
+ Args:
277
+ data (str): The corresponding data in the Authorization header.
278
+ is_base64 (bool, optional): Whether the provided data is base64 encoded. Defaults to True.
279
+ skip_apikey (bool, optional): Whether to skip apikey validation. Defaults to False.
280
+ skip_password (bool, optional): Whether to skip password validation. Defaults to False.
281
+
282
+ Raises:
283
+ AuthenticationException: The login information is invalid, or the maximum password retry for the account
284
+ has been reached.
285
+
286
+ Returns:
287
+ tuple[Optional[User], Optional[list[str]]]: The user odm object and privileges, if validated
288
+ """
289
+ key_pair = decode_b64(data) if is_base64 else data
290
+
291
+ [username, data] = key_pair.split(":", maxsplit=1)
292
+
293
+ validated_user = None
294
+ if not skip_apikey:
295
+ validated_user, priv = validate_apikey(username, data)
296
+
297
+ # Bruteforce protection
298
+ auth_fail_queue: NamedQueue = NamedQueue(f"ui-failed-{username}", **nonpersistent_config) # type: ignore
299
+ if auth_fail_queue.length() >= config.auth.internal.max_failures:
300
+ # Failed 'max_failures' times, stop trying... This will timeout in 'failure_ttl' seconds
301
+ raise AuthenticationException(
302
+ "Maximum password retry of {retry} was reached. "
303
+ "This account is locked for the next {ttl} "
304
+ "seconds...".format(
305
+ retry=config.auth.internal.max_failures,
306
+ ttl=config.auth.internal.failure_ttl,
307
+ )
308
+ )
309
+
310
+ if not validated_user and not skip_password:
311
+ validated_user, priv = validate_userpass(username, data)
312
+
313
+ if not validated_user:
314
+ auth_fail_queue.push(
315
+ {
316
+ "remote_addr": request.remote_addr,
317
+ "host": request.host,
318
+ "full_path": request.full_path,
319
+ }
320
+ )
321
+ raise AuthenticationException("Invalid login information")
322
+
323
+ return validated_user, priv
@@ -0,0 +1,128 @@
1
+ from datetime import datetime
2
+ from math import ceil
3
+ from typing import Optional
4
+
5
+ from flask import request
6
+
7
+ import howler.services.hit_service as hit_service
8
+ from howler.common.exceptions import ForbiddenException, HowlerException
9
+ from howler.common.loader import get_lookups
10
+ from howler.common.logging import get_logger
11
+ from howler.config import CLASSIFICATION, config, get_branch, get_commit, get_version
12
+ from howler.helper.discover import get_apps_list
13
+ from howler.helper.search import list_all_fields
14
+ from howler.odm.models.howler_data import Assessment, Escalation, HitStatus, Scrutiny
15
+ from howler.odm.models.user import User
16
+ from howler.plugins import get_plugins
17
+ from howler.services import jwt_service
18
+ from howler.utils.str_utils import default_string_value
19
+
20
+ classification_definition = CLASSIFICATION.get_parsed_classification_definition()
21
+
22
+ lookups = get_lookups()
23
+
24
+ logger = get_logger()
25
+
26
+
27
+ def _get_apikey_max_duration():
28
+ "Configure the maximum duration of a created API key"
29
+ amount, unit = (
30
+ config.auth.max_apikey_duration_amount,
31
+ config.auth.max_apikey_duration_unit,
32
+ )
33
+
34
+ if not config.auth.oauth.strict_apikeys:
35
+ return amount, unit
36
+
37
+ auth_header: Optional[str] = request.headers.get("Authorization", None)
38
+
39
+ if not auth_header:
40
+ return amount, unit
41
+
42
+ if not auth_header.startswith("Bearer") or "." not in auth_header:
43
+ return amount, unit
44
+
45
+ oauth_token = auth_header.split(" ")[1]
46
+ try:
47
+ data = jwt_service.decode(
48
+ oauth_token,
49
+ validate_audience=False,
50
+ options={"verify_signature": False},
51
+ )
52
+ amount, unit = (
53
+ ceil((datetime.fromtimestamp(data["exp"]) - datetime.now()).total_seconds()),
54
+ "seconds",
55
+ )
56
+ except ForbiddenException:
57
+ logger.warning("Access token is expired.")
58
+ except HowlerException:
59
+ logger.exception("Error occurred when decoding access token.")
60
+ finally:
61
+ return amount, unit
62
+
63
+
64
+ def get_configuration(user: User | None, **kwargs):
65
+ """Get system configration data for the Howler API
66
+
67
+ Args:
68
+ user (User): The user making the request
69
+ """
70
+ apps = get_apps_list(discovery_url=kwargs.get("discovery_url", None))
71
+
72
+ amount, unit = _get_apikey_max_duration()
73
+
74
+ plugin_features: dict[str, bool] = {}
75
+
76
+ for plugin in get_plugins():
77
+ try:
78
+ plugin_features = {**plugin_features, **plugin.features}
79
+ except (ImportError, AttributeError):
80
+ pass
81
+
82
+ return {
83
+ "lookups": {
84
+ "howler.status": HitStatus.list(),
85
+ "howler.scrutiny": Scrutiny.list(),
86
+ "howler.escalation": Escalation.list(),
87
+ "howler.assessment": Assessment.list(),
88
+ "transitions": {status: hit_service.get_transitions(status) for status in HitStatus.list()},
89
+ **lookups,
90
+ },
91
+ "configuration": {
92
+ "auth": {
93
+ "allow_apikeys": config.auth.allow_apikeys,
94
+ "allow_extended_apikeys": config.auth.allow_extended_apikeys,
95
+ "max_apikey_duration_amount": amount,
96
+ "max_apikey_duration_unit": unit,
97
+ "oauth_providers": [
98
+ name
99
+ for name, p in config.auth.oauth.providers.items()
100
+ if default_string_value(p.client_secret, env_name=f"{name.upper()}_CLIENT_SECRET")
101
+ ],
102
+ "internal": {"enabled": config.auth.internal.enabled},
103
+ },
104
+ "system": {
105
+ "type": config.system.type,
106
+ "version": get_version(),
107
+ "branch": get_branch(),
108
+ "commit": get_commit(),
109
+ "retention": {
110
+ "enabled": config.system.retention.enabled,
111
+ "limit_unit": config.system.retention.limit_unit,
112
+ "limit_amount": config.system.retention.limit_amount,
113
+ },
114
+ },
115
+ "ui": {
116
+ "apps": apps,
117
+ },
118
+ "mapping": config.mapping,
119
+ "features": {
120
+ "clue": config.core.clue.enabled,
121
+ "notebook": config.core.notebook.enabled,
122
+ **plugin_features,
123
+ },
124
+ "clue": {"status_checks": config.core.clue.status_checks},
125
+ },
126
+ "c12nDef": classification_definition,
127
+ "indexes": list_all_fields("admin" in user["type"] if user is not None else False),
128
+ }
@@ -0,0 +1,252 @@
1
+ """Dossier service module for managing security investigation dossiers.
2
+
3
+ This module provides functionality for creating, updating, retrieving, and managing
4
+ dossiers - collections of security alerts and investigation data organized by analysts.
5
+ Dossiers can be personal (private to the creator) or global (shared with the team).
6
+ """
7
+
8
+ from typing import Any, Optional, cast
9
+
10
+ from mergedeep.mergedeep import merge
11
+
12
+ from howler.common.exceptions import ForbiddenException, HowlerException, InvalidDataException, NotFoundException
13
+ from howler.common.loader import datastore
14
+ from howler.common.logging import get_logger
15
+ from howler.datastore.exceptions import SearchException
16
+ from howler.odm.models.dossier import Dossier
17
+ from howler.odm.models.user import User
18
+ from howler.services import lucene_service
19
+
20
+ logger = get_logger(__file__)
21
+
22
+ # Define which fields are allowed to be updated in a dossier, preventing unauthorized modification of sensitive fields
23
+ PERMITTED_KEYS = {
24
+ "title",
25
+ "query",
26
+ "leads",
27
+ "pivots",
28
+ "type",
29
+ "owner",
30
+ }
31
+
32
+
33
+ def exists(dossier_id: str) -> bool:
34
+ """Check if a dossier exists in the datastore.
35
+
36
+ Args:
37
+ dossier_id: Unique identifier for the dossier
38
+
39
+ Returns:
40
+ True if the dossier exists, False otherwise
41
+ """
42
+ return datastore().dossier.exists(dossier_id)
43
+
44
+
45
+ def get_dossier(
46
+ id: str,
47
+ as_odm: bool = False,
48
+ version: bool = False,
49
+ ) -> Dossier:
50
+ """Retrieve a dossier from the datastore.
51
+
52
+ Args:
53
+ id: Unique identifier for the dossier
54
+ as_odm: Whether to return as ODM object (True) or dictionary (False)
55
+ version: Whether to include version information in the response
56
+
57
+ Returns:
58
+ Dossier object or dictionary containing dossier data
59
+
60
+ Raises:
61
+ NotFoundException: If the dossier doesn't exist
62
+ """
63
+ return datastore().dossier.get_if_exists(key=id, as_obj=as_odm, version=version)
64
+
65
+
66
+ def create_dossier(dossier_data: Optional[Any], username: str) -> Dossier: # noqa: C901
67
+ """Create a new dossier in the datastore.
68
+
69
+ This function validates the input data, ensures the query is valid by testing it
70
+ against the hit collection, and creates a new dossier with the specified parameters.
71
+
72
+ Args:
73
+ dossier_data: Dictionary containing dossier configuration data
74
+ username: Username of the user creating the dossier
75
+
76
+ Returns:
77
+ Newly created Dossier object
78
+
79
+ Raises:
80
+ InvalidDataException: If data format is invalid, required fields are missing,
81
+ or the query is invalid
82
+ HowlerException: If there's an error during dossier creation
83
+ """
84
+ # Validate input data format
85
+ if not isinstance(dossier_data, dict):
86
+ raise InvalidDataException("Invalid data format")
87
+
88
+ # Validate required fields for dossier creation
89
+ if "title" not in dossier_data:
90
+ raise InvalidDataException("You must specify a title when creating a dossier.")
91
+
92
+ if "query" not in dossier_data:
93
+ raise InvalidDataException("You must specify a query when creating a dossier.")
94
+
95
+ if "type" not in dossier_data:
96
+ raise InvalidDataException("You must specify a type when creating a dossier.")
97
+
98
+ storage = datastore()
99
+
100
+ try:
101
+ # Validate the Lucene query by attempting to search with it
102
+ # This ensures the query syntax is correct before saving the dossier
103
+ if query := dossier_data.get("query", None):
104
+ storage.hit.search(query)
105
+
106
+ if "owner" not in dossier_data:
107
+ dossier_data["owner"] = username
108
+
109
+ dossier = Dossier(dossier_data)
110
+
111
+ # Validate pivot configurations to ensure no duplicate mapping keys
112
+ for pivot in dossier.pivots:
113
+ if len(pivot.mappings) != len(set(mapping.key for mapping in pivot.mappings)):
114
+ raise InvalidDataException("One of your pivots has duplicate keys set.")
115
+
116
+ # Ensure the owner is set to the current user (security measure)
117
+ dossier.owner = username
118
+
119
+ # Save the dossier to the datastore
120
+ storage.dossier.save(dossier.dossier_id, dossier)
121
+
122
+ # Commit the transaction to persist changes
123
+ storage.dossier.commit()
124
+
125
+ return dossier
126
+ except SearchException:
127
+ # Handle invalid Lucene query syntax
128
+ raise InvalidDataException("You must use a valid query when creating a dossier.")
129
+ except HowlerException as e:
130
+ # Handle other application-specific errors
131
+ raise InvalidDataException(str(e))
132
+
133
+
134
+ def update_dossier(dossier_id: str, dossier_data: dict[str, Any], user: User) -> Dossier: # noqa: C901
135
+ """Update one or more properties of a dossier in the database.
136
+
137
+ This function enforces access control rules and validates data before updating.
138
+ Personal dossiers can only be updated by their owners or admins.
139
+ Global dossiers can only be updated by their owners or admins.
140
+
141
+ Args:
142
+ dossier_id: Unique identifier of the dossier to update
143
+ dossier_data: Dictionary containing fields to update
144
+ user: User object representing the requesting user
145
+
146
+ Returns:
147
+ Updated Dossier object
148
+
149
+ Raises:
150
+ NotFoundException: If the dossier doesn't exist
151
+ InvalidDataException: If invalid fields are provided or data is malformed
152
+ ForbiddenException: If user lacks permission to update the dossier
153
+ """
154
+ # Verify the dossier exists before attempting to update
155
+ if not exists(dossier_id):
156
+ raise NotFoundException(f"Dossier with id '{dossier_id}' does not exist.")
157
+
158
+ # Validate that only permitted fields are being updated
159
+ # This prevents unauthorized modification of sensitive fields
160
+ if set(dossier_data.keys()) - PERMITTED_KEYS:
161
+ raise InvalidDataException(f"Only {', '.join(PERMITTED_KEYS)} can be updated.")
162
+
163
+ storage = datastore()
164
+
165
+ # Retrieve the existing dossier for access control checks
166
+ existing_dossier: Dossier = get_dossier(dossier_id, as_odm=True)
167
+
168
+ # Enforce access control for personal dossiers
169
+ # Only the owner or admin users can modify personal dossiers
170
+ if existing_dossier.type == "personal" and existing_dossier.owner != user.uname and "admin" not in user.type:
171
+ raise ForbiddenException("You cannot update a personal dossier that is not owned by you.")
172
+
173
+ # Enforce access control for global dossiers
174
+ # Only the owner or admin users can modify global dossiers
175
+ if existing_dossier.type == "global" and existing_dossier.owner != user.uname and "admin" not in user.type:
176
+ raise ForbiddenException("Only the owner of a dossier and administrators can edit a global dossier.")
177
+
178
+ # Validate pivot configurations if they're being updated
179
+ # Ensure no duplicate mapping keys exist within any pivot
180
+ if "pivots" in dossier_data:
181
+ for pivot in dossier_data["pivots"]:
182
+ if len(pivot["mappings"]) != len(set(mapping["key"] for mapping in pivot["mappings"])):
183
+ raise InvalidDataException("One of your pivots has duplicate keys set.")
184
+
185
+ try:
186
+ # Validate the Lucene query if it's being updated
187
+ if "query" in dossier_data:
188
+ # Test the query against the hit index to ensure it's valid
189
+ storage.hit.search(dossier_data["query"])
190
+
191
+ # Merge the new data with existing dossier data
192
+ new_data = Dossier(merge({}, existing_dossier.as_primitives(), dossier_data))
193
+
194
+ storage.dossier.save(dossier_id, new_data)
195
+
196
+ # Commit the transaction to persist changes
197
+ storage.dossier.commit()
198
+
199
+ return new_data
200
+ except SearchException:
201
+ # Handle invalid Lucene query syntax
202
+ raise InvalidDataException("You must use a valid query when updating a dossier.")
203
+ except (HowlerException, TypeError) as e:
204
+ # Log the error for debugging purposes
205
+ logger.exception("Error when updating dossier.")
206
+ # Provide a user-friendly error message while preserving the original exception
207
+ raise InvalidDataException("We were unable to update the dossier.", cause=e) from e
208
+
209
+
210
+ def get_matching_dossiers(hit: dict[str, Any], dossiers: Optional[list[dict[str, Any]]] = None):
211
+ """Get a list of dossiers that match a specific security alert/hit.
212
+
213
+ This function evaluates each dossier's query against the provided hit data
214
+ to determine which dossiers are relevant to the security event.
215
+
216
+ Args:
217
+ hit: Dictionary containing security alert/hit data to match against
218
+ dossiers: Optional list of dossiers to check. If None, all dossiers
219
+ will be retrieved from the datastore
220
+
221
+ Returns:
222
+ List of dossier dictionaries that match the provided hit
223
+
224
+ Note:
225
+ This function uses Lucene query matching to determine relevance.
226
+ Dossiers with no query are assumed to match all hits.
227
+ """
228
+ # Retrieve all dossiers if none provided
229
+ if dossiers is None:
230
+ dossiers: list[dict[str, Any]] = datastore().dossier.search(
231
+ "dossier_id:*",
232
+ as_obj=False,
233
+ # TODO: Eventually implement caching here
234
+ rows=1000,
235
+ )["items"]
236
+
237
+ matching_dossiers: list[dict[str, Any]] = []
238
+
239
+ # Evaluate each dossier against the hit data
240
+ for dossier in cast(list[dict[str, Any]], dossiers):
241
+ # Dossiers without queries match all hits by default
242
+ # This allows for catch-all dossiers that collect all security events
243
+ if "query" not in dossier or dossier["query"] is None:
244
+ matching_dossiers.append(dossier)
245
+ continue
246
+
247
+ # Use Lucene service to check if the hit matches the dossier's query
248
+ # This determines if the security event is relevant to this investigation
249
+ if lucene_service.match(dossier["query"], hit):
250
+ matching_dossiers.append(dossier)
251
+
252
+ return matching_dossiers