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
howler/helper/hit.py ADDED
@@ -0,0 +1,236 @@
1
+ from typing import Any, Optional, Union
2
+
3
+ from howler.common.exceptions import InvalidDataException
4
+ from howler.common.logging import get_logger
5
+ from howler.datastore.operations import OdmHelper, OdmUpdateOperation
6
+ from howler.helper.workflow import Transition
7
+ from howler.odm.models.hit import Hit
8
+ from howler.odm.models.howler_data import (
9
+ Assessment,
10
+ AssessmentEscalationMap,
11
+ Escalation,
12
+ HitStatus,
13
+ HitStatusTransition,
14
+ Vote,
15
+ )
16
+ from howler.odm.models.user import User
17
+
18
+ odm_helper = OdmHelper(Hit)
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def assess_hit(
24
+ assessment: Optional[str] = None,
25
+ rationale: Optional[str] = None,
26
+ hit: Optional[Union[dict[str, Any], Hit]] = None,
27
+ **kwargs,
28
+ ) -> list[OdmUpdateOperation]:
29
+ """Update the assessment and esclation of a hit
30
+
31
+ Args:
32
+ assessment (Optional[str], optional): The assessment to set the hit to. Defaults to None.
33
+ hit (Optional[dict[str, Any]], optional): The hit to update. Defaults to None.
34
+
35
+ Raises:
36
+ InvalidDataException: An invalid assessment was provided
37
+
38
+ Returns:
39
+ list[OdmUpdateOperation]: A list of the opperations to run on the hit
40
+ """
41
+ escalation: Optional[str] = None
42
+ if not assessment:
43
+ # In case the assessment is set to empty string
44
+ assessment = None
45
+ else:
46
+ if assessment not in Assessment:
47
+ assessment_list = ", ".join(Assessment)
48
+ raise InvalidDataException(f"Must set assessment to one of {assessment_list}.")
49
+
50
+ escalation = AssessmentEscalationMap[assessment]
51
+
52
+ if assessment is None and rationale:
53
+ rationale = None
54
+
55
+ logger.debug(
56
+ "Updating assessment of %s to %s",
57
+ hit["howler"]["id"] if hit else "unknown",
58
+ assessment,
59
+ )
60
+ logger.debug(
61
+ "Updating escalation of %s to %s",
62
+ hit["howler"]["id"] if hit else "unknown",
63
+ escalation,
64
+ )
65
+
66
+ return [
67
+ odm_helper.update("howler.assessment", assessment),
68
+ odm_helper.update("howler.escalation", escalation),
69
+ odm_helper.update("howler.rationale", rationale, silent=True),
70
+ ]
71
+
72
+
73
+ def unassign_hit(
74
+ hit: dict[str, Any],
75
+ user: Optional[User] = None,
76
+ **kwargs,
77
+ ) -> list[OdmUpdateOperation]:
78
+ """Remove the assignment of a hit
79
+
80
+ Args:
81
+ user (Optional[User], optional): The user unassigning the hit. Defaults to None.
82
+ hit (Optional[dict[str, Any]], optional): The hit to unassign the user from. Defaults to None.
83
+
84
+ Raises:
85
+ InvalidDataException: The user unassigning the hit doesn't have the hit assigned to them
86
+
87
+ Returns:
88
+ list[OdmUpdateOperation]: A list of the operations necessary to update the hit
89
+ """
90
+ if user and hit["howler"]["assignment"] == user.get("uname", user.get("username", None)):
91
+ return [odm_helper.update("howler.assignment", "unassigned")]
92
+
93
+ raise InvalidDataException("Cannot release hit that isn't assigned to you.")
94
+
95
+
96
+ def assign_hit(
97
+ transition: Transition,
98
+ user: Optional[User] = None,
99
+ assignee: Optional[str] = None,
100
+ hit: Optional[dict[str, Any]] = None,
101
+ **kwargs,
102
+ ) -> list[OdmUpdateOperation]:
103
+ """Assign a hit to a user
104
+
105
+ Args:
106
+ transition (Transition): The type of transition being used to assign the hit
107
+ user (Optional[User], optional): The user assigning the hit. Defaults to None.
108
+ assignee (Optional[str], optional): The user to assign the hit to. Defaults to None.
109
+ hit (Optional[dict[str, Any]], optional): The hit we are assigning. Defaults to None.
110
+
111
+ Raises:
112
+ InvalidDataException: Incorrect parameters were provided
113
+
114
+ Returns:
115
+ list[OdmUpdateOperation]: A list of operations to update the hit assignment
116
+ """
117
+ if transition["transition"] == HitStatusTransition.ASSIGN_TO_OTHER:
118
+ if not assignee:
119
+ raise InvalidDataException("Must specify an assignee when assigning to another user.")
120
+
121
+ if hit and hit["howler"]["assignment"] == assignee:
122
+ raise InvalidDataException("Must specify an assignee that is different from the current assigned user.")
123
+
124
+ if not user and not assignee:
125
+ raise InvalidDataException("Could not assign Hit to user a no 'user_id' was provided")
126
+
127
+ return [
128
+ odm_helper.update(
129
+ "howler.assignment",
130
+ assignee or user.get("uname", user.get("username", None)) if user else None,
131
+ )
132
+ ]
133
+
134
+
135
+ def check_ownership(
136
+ hit: dict[str, Any],
137
+ user: Optional[dict[str, Any]] = None,
138
+ **kwargs,
139
+ ) -> list[OdmUpdateOperation]:
140
+ """Check the ownership of a hit, and throw an exception if it doesnt match
141
+
142
+ Args:
143
+ hit (dict[str, Any]): The hit to check
144
+ user (Optional[dict[str, Any]], optional): The user to check for ownership of. Defaults to None.
145
+
146
+ Raises:
147
+ InvalidDataException: Raised when the hit assignee doesn't match the user
148
+
149
+ Returns:
150
+ list[OdmUpdateOperation]: An empty list
151
+ """
152
+ if user and hit["howler"]["assignment"] != user.get("uname", user.get("username", None)):
153
+ raise InvalidDataException("Cannot use this transition when the hit is not assigned to you.")
154
+
155
+ return []
156
+
157
+
158
+ def promote_hit(**kwargs) -> list[OdmUpdateOperation]:
159
+ """Promote a hit to an alert
160
+
161
+ Returns:
162
+ list[OdmUpdateOperation]: The update to run to promote
163
+ """
164
+ return [odm_helper.update("howler.escalation", kwargs.get("escalation", Escalation.ALERT))]
165
+
166
+
167
+ def demote_hit(**kwargs) -> list[OdmUpdateOperation]:
168
+ """Demote an alert to a hit
169
+
170
+ Returns:
171
+ list[OdmUpdateOperation]: The update to run to demote
172
+ """
173
+ return [odm_helper.update("howler.escalation", kwargs.get("escalation", Escalation.HIT))]
174
+
175
+
176
+ def vote_hit(
177
+ hit: dict[str, Any],
178
+ vote: str,
179
+ email: str,
180
+ user: Optional[dict[str, Any]] = None,
181
+ **kwargs,
182
+ ) -> list[OdmUpdateOperation]:
183
+ """Add a vote to the given hit
184
+
185
+ Args:
186
+ hit (dict[str, Any]): The hit to add the vote to
187
+ vote (str): The type of vote to add
188
+ email (str): The email of the user voting
189
+ user (Optional[dict[str, Any]], optional): The user voting. Defaults to None.
190
+
191
+ Raises:
192
+ InvalidDataException: Invalid data was provided
193
+
194
+ Returns:
195
+ list[OdmUpdateOperation]: A list of operations to update the hit depending on the vote
196
+ """
197
+ if not email:
198
+ raise InvalidDataException("Could not vote on Hit as no email was provided")
199
+
200
+ if vote not in Vote or vote == "" or vote is None:
201
+ raise InvalidDataException(f"vote is not optional. Provide a value from: {', '.join(Vote)}")
202
+
203
+ actions = []
204
+
205
+ # Check to see if there is an existing vote from this user
206
+ old_vote = (
207
+ "benign"
208
+ if email in hit["howler"]["votes"]["benign"]
209
+ else (
210
+ "obscure"
211
+ if email in hit["howler"]["votes"]["obscure"]
212
+ else "malicious"
213
+ if email in hit["howler"]["votes"]["malicious"]
214
+ else None
215
+ )
216
+ )
217
+
218
+ if old_vote:
219
+ logger.debug("removing old vote of %s from %s", old_vote, id)
220
+ actions.append(odm_helper.list_remove(f"howler.votes.{old_vote}", email))
221
+
222
+ if not old_vote or old_vote != vote:
223
+ logger.debug("Adding vote of %s to %s", vote, id)
224
+ actions.append(odm_helper.list_add(f"howler.votes.{vote}", email, if_missing=True))
225
+
226
+ if user and hit["howler"]["assignment"] == user.get("uname", user.get("username", None)):
227
+ if hit["howler"]["status"] in [
228
+ HitStatus.IN_PROGRESS,
229
+ HitStatus.OPEN,
230
+ ]:
231
+ actions.append(odm_helper.update("howler.assignment", "unassigned"))
232
+ actions.append(odm_helper.update("howler.status", HitStatus.OPEN))
233
+ else:
234
+ raise InvalidDataException("Cannot vote on hit you are assigned to.")
235
+
236
+ return actions
howler/helper/oauth.py ADDED
@@ -0,0 +1,247 @@
1
+ import base64
2
+ import hashlib
3
+ import re
4
+ from typing import Any, Optional
5
+
6
+ import elasticapm
7
+ import requests
8
+
9
+ from howler.common.exceptions import HowlerException, HowlerValueError
10
+ from howler.common.loader import USER_TYPES
11
+ from howler.common.logging import get_logger
12
+ from howler.common.random_user import random_user
13
+ from howler.config import CLASSIFICATION as CLASSIFICATION_ENGINE
14
+ from howler.config import config
15
+ from howler.helper.azure import azure_obo
16
+ from howler.odm.models.config import OAuthProvider
17
+ from howler.services import jwt_service
18
+
19
+ VALID_CHARS = [str(x) for x in range(10)] + [chr(x + 65) for x in range(26)] + [chr(x + 97) for x in range(26)] + ["-"]
20
+
21
+ logger = get_logger(__file__)
22
+
23
+
24
+ def reorder_name(name: Optional[str]) -> Optional[str]:
25
+ """Reorder the name from Doe, John to John Doe"""
26
+ if name is None:
27
+ return name
28
+
29
+ return " ".join(name.split(", ", 1)[::-1])
30
+
31
+
32
+ @elasticapm.capture_span(span_type="authentication")
33
+ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> dict[str, Any]: # noqa: C901
34
+ """Parse a raw profile dict into a useful user data dict"""
35
+ # Find email address and normalize it for further processing
36
+ email_adr = profile.get(
37
+ "email",
38
+ profile.get("emails", profile.get("preferred_username", profile.get("upn", None))),
39
+ )
40
+
41
+ if isinstance(email_adr, list):
42
+ email_adr = email_adr[0]
43
+
44
+ if email_adr:
45
+ email_adr = email_adr.lower()
46
+ if "@" not in email_adr:
47
+ email_adr = None
48
+
49
+ # Find the name of the user
50
+ name = reorder_name(profile.get("name", profile.get("displayName", None)))
51
+
52
+ # Generate a username
53
+ if provider_config.uid_randomize:
54
+ # Use randomizer
55
+ uname = random_user(
56
+ digits=provider_config.uid_randomize_digits,
57
+ delimiter=provider_config.uid_randomize_delimiter,
58
+ )
59
+ else:
60
+ # Try and find the username
61
+ uname = profile.get("uname", profile.get("preferred_username", email_adr))
62
+
63
+ # Did we default to email?
64
+ if email_adr is not None and uname is not None and uname.lower() == email_adr.lower():
65
+ # 1. Use provided regex matcher
66
+ if provider_config.uid_regex:
67
+ match = re.match(provider_config.uid_regex, uname)
68
+ if match:
69
+ if provider_config.uid_format:
70
+ uname = provider_config.uid_format.format(*[x or "" for x in match.groups()]).lower()
71
+ else:
72
+ uname = "".join([x for x in match.groups() if x]).lower()
73
+
74
+ # 2. Parse name and domain from email if regex failed or missing
75
+ if uname is not None and uname == email_adr:
76
+ e_name, e_dom = uname.split("@", 1)
77
+ uname = f"{e_name}-{e_dom.split('.')[0]}"
78
+
79
+ # 3. Use name as username if there are no username found yet
80
+ if uname is None and name is not None:
81
+ uname = name.replace(" ", "-")
82
+
83
+ # Cleanup username
84
+ if uname:
85
+ uname = "".join([c for c in uname if c in VALID_CHARS])
86
+
87
+ # Get avatar from gravatar
88
+ if config.auth.oauth.gravatar_enabled and email_adr:
89
+ email_hash = hashlib.md5(email_adr.encode("utf-8")).hexdigest() # noqa: S324
90
+ alternate = f"https://www.gravatar.com/avatar/{email_hash}?s=256&d=404&r=pg"
91
+ else:
92
+ alternate = None
93
+
94
+ # Compute access, roles and classification using auto_properties
95
+ access = True
96
+ roles = ["user"]
97
+ classification = CLASSIFICATION_ENGINE.UNRESTRICTED
98
+ if provider_config.auto_properties:
99
+ for auto_prop in provider_config.auto_properties:
100
+ if auto_prop.type == "access":
101
+ # Set default access value for access pattern
102
+ access = auto_prop.value != "True"
103
+
104
+ # Get values for field
105
+ field_data = profile.get(auto_prop.field, None)
106
+ if not isinstance(field_data, list):
107
+ field_data = [field_data]
108
+
109
+ # Analyse field values
110
+ for value in field_data:
111
+ # If there is no value, no need to do any tests
112
+ if value is None:
113
+ continue
114
+
115
+ # Check access
116
+ if auto_prop.type == "access":
117
+ if re.match(auto_prop.pattern, value) is not None:
118
+ access = auto_prop.value == "True"
119
+ break
120
+
121
+ # Append roles from matching patterns
122
+ elif auto_prop.type == "role":
123
+ if re.match(auto_prop.pattern, value):
124
+ roles.append(auto_prop.value)
125
+ break
126
+
127
+ # Compute classification from matching patterns
128
+ elif auto_prop.type == "classification":
129
+ if re.match(auto_prop.pattern, value):
130
+ classification = CLASSIFICATION_ENGINE.build_user_classification(
131
+ classification, auto_prop.value
132
+ )
133
+ break
134
+
135
+ # Infer roles from groups
136
+ if profile.get("groups") and provider_config.role_map:
137
+ for user_type in USER_TYPES:
138
+ if (
139
+ user_type in provider_config.role_map
140
+ and provider_config.role_map[user_type] in profile.get("groups", [])
141
+ and user_type not in roles
142
+ ):
143
+ roles.append(user_type)
144
+
145
+ return dict(
146
+ access=access,
147
+ type=roles,
148
+ classification=classification,
149
+ uname=uname,
150
+ name=name,
151
+ email=email_adr,
152
+ password="__NO_PASSWORD__", # noqa: S106
153
+ avatar=profile.get("picture", provider_config.picture_url or alternate),
154
+ groups=profile.get("groups", []),
155
+ )
156
+
157
+
158
+ def fetch_avatar( # noqa: C901
159
+ url: str, provider: dict[str, Any], oauth_provider: str, access_token: Optional[str] = None
160
+ ):
161
+ """Fetch a user's avatar form the oauth provider"""
162
+ provider_config = config.auth.oauth.providers[oauth_provider]
163
+
164
+ logger.info("Fetching avatar from %s at %s", oauth_provider, url)
165
+
166
+ try:
167
+ # Generic picture url endpoint, i.e. MS Graph
168
+ if url == provider_config.picture_url:
169
+ headers = {}
170
+
171
+ if oauth_provider == "azure":
172
+ if not access_token:
173
+ raise HowlerValueError("An azure access token is necessary to retrieve the profile picture") # noqa: TRY301
174
+
175
+ token = azure_obo(access_token)
176
+
177
+ if token:
178
+ headers["Authorization"] = f"Bearer {token}"
179
+
180
+ resp: Any = requests.get(url, headers=headers, timeout=10)
181
+
182
+ if resp.ok and resp.headers.get("content-type") is not None:
183
+ b64_img = base64.b64encode(resp.content).decode()
184
+ avatar = f'data:{resp.headers.get("content-type")};base64,{b64_img}'
185
+ return avatar
186
+
187
+ # Url that is protected through OAuth
188
+ elif provider_config.api_base_url and url.startswith(provider_config.api_base_url):
189
+ resp = provider.get(url[len(provider_config.api_base_url) :])
190
+ if resp.ok and resp.headers.get("content-type") is not None:
191
+ b64_img = base64.b64encode(resp.content).decode()
192
+ avatar = f'data:{resp.headers.get("content-type")};base64,{b64_img}'
193
+ return avatar
194
+
195
+ # Unprotected url
196
+ elif url.startswith(("https://", "http://")):
197
+ resp = requests.get(url, timeout=10)
198
+ if resp.ok and resp.headers.get("content-type") is not None:
199
+ b64_img = base64.b64encode(resp.content).decode()
200
+ avatar = f'data:{resp.headers.get("content-type")};base64,{b64_img}'
201
+ return avatar
202
+
203
+ # Quietly fail, it'll use gravatar instead
204
+ except Exception as e:
205
+ logger.warning("Error while retrieving user profile: %s", str(e))
206
+ return None
207
+
208
+
209
+ def fetch_groups(token: str):
210
+ """Fetch a user's groups form an external endpoint"""
211
+ oauth_provider = jwt_service.get_provider(token)
212
+ oauth_provider_config = config.auth.oauth.providers[oauth_provider]
213
+
214
+ if oauth_provider_config.groups_url:
215
+ if oauth_provider == "azure":
216
+ try:
217
+ token = azure_obo(token)
218
+ except HowlerException:
219
+ logger.exception("Exception on fetching groups data")
220
+ raise HowlerException("Something went wrong when getting the detailed groups data.")
221
+
222
+ headers = {}
223
+ if token:
224
+ headers["Authorization"] = f"Bearer {token}"
225
+
226
+ resp = requests.get(oauth_provider_config.groups_url, headers=headers, timeout=10)
227
+
228
+ if resp.ok and resp.headers.get("content-type") is not None:
229
+ result = resp.json()
230
+ if oauth_provider_config.groups_key:
231
+ for part in oauth_provider_config.groups_key.split("."):
232
+ result = result[part]
233
+
234
+ detailed_group_data = []
235
+ for group in result:
236
+ detailed_group_data.append(
237
+ {
238
+ "id": group.get("id", None),
239
+ "name": group.get("name", group.get("displayName", group.get("id", None))),
240
+ }
241
+ )
242
+
243
+ return sorted(detailed_group_data, key=lambda g: g.get("name", "").lower())
244
+
245
+ raise HowlerException("Something went wrong when getting the detailed groups data.")
246
+ else:
247
+ return None
@@ -0,0 +1,92 @@
1
+ from typing import Any, Callable, Optional, Union
2
+
3
+ from howler.common.loader import datastore
4
+ from howler.datastore.collection import ESCollection
5
+ from howler.odm.models.user import User
6
+
7
+ # List of indices where queries are protected with classification access control
8
+ ACCESS_CONTROLLED_INDICES: dict[str, ESCollection] = {}
9
+
10
+ ADMIN_INDEX_MAP: dict[str, Callable[[], ESCollection]] = {}
11
+
12
+ ADMIN_INDEX_ORDER_MAP: dict[str, str] = {}
13
+
14
+ INDEX_MAP: dict[str, Callable[[], ESCollection]] = {
15
+ "action": lambda: datastore().action,
16
+ "analytic": lambda: datastore().analytic,
17
+ "dossier": lambda: datastore().dossier,
18
+ "hit": lambda: datastore().hit,
19
+ "overview": lambda: datastore().overview,
20
+ "template": lambda: datastore().template,
21
+ "user": lambda: datastore().user,
22
+ "view": lambda: datastore().view,
23
+ }
24
+
25
+ INDEX_ORDER_MAP: dict[str, str] = {
26
+ "action": "name asc",
27
+ "analytic": "name asc",
28
+ "dossier": "title asc",
29
+ "hit": "event.created desc",
30
+ "overview": "overview_id asc",
31
+ "template": "template_id asc",
32
+ "user": "id asc",
33
+ "view": "title asc",
34
+ }
35
+
36
+
37
+ def get_collection(index: str, user: Union[User, dict[str, Any]]) -> Optional[Callable[[], ESCollection]]:
38
+ """Get the ESCollection for a given index
39
+
40
+ Args:
41
+ index (str): The name of the ESCollection to retrieve
42
+ user (User): The user retrieving the collection
43
+
44
+ Returns:
45
+ ESCollection: The corresponding ESCollection
46
+ """
47
+ return INDEX_MAP.get(index, ADMIN_INDEX_MAP.get(index, None) if "admin" in user["type"] else None)
48
+
49
+
50
+ def get_default_sort(index: str, user: Union[User, dict[str, Any]]) -> Optional[str]:
51
+ """Retrieve the default sorting for a given index
52
+
53
+ Args:
54
+ index (str): The index to get the default sort of
55
+ user (Union[User, dict[str, Any]]): The user retrieving the collection
56
+
57
+ Returns:
58
+ str: The default sort for the index
59
+ """
60
+ return INDEX_ORDER_MAP.get(
61
+ index,
62
+ ADMIN_INDEX_ORDER_MAP.get(index, None) if "admin" in user["type"] else None,
63
+ )
64
+
65
+
66
+ def has_access_control(index: str) -> bool:
67
+ """Check if the given index has access control enabled
68
+
69
+ Args:
70
+ index (str): The index to check
71
+
72
+ Returns:
73
+ bool: Does the index have access control
74
+ """
75
+ return index in ACCESS_CONTROLLED_INDICES
76
+
77
+
78
+ def list_all_fields(is_admin: bool = False) -> dict[str, dict]:
79
+ """Generate a list of all fields in each index
80
+
81
+ Args:
82
+ is_admin (bool, optional): Should administrator only indexes be included? Defaults to False.
83
+
84
+ Returns:
85
+ dict[str, dict]: A list of all fields in each index
86
+ """
87
+ fields_map = {k: INDEX_MAP[k]().fields(skip_mapping_children=True) for k in INDEX_MAP.keys()}
88
+
89
+ if is_admin:
90
+ fields_map.update({k: ADMIN_INDEX_MAP[k]().fields(skip_mapping_children=True) for k in ADMIN_INDEX_MAP.keys()})
91
+
92
+ return fields_map
@@ -0,0 +1,110 @@
1
+ from typing import Callable, Optional, TypedDict, Union
2
+
3
+ from howler.common.exceptions import HowlerException
4
+ from howler.datastore.collection import ESCollection
5
+ from howler.datastore.operations import OdmUpdateOperation
6
+
7
+
8
+ class WorkflowException(HowlerException):
9
+ "Exception for errors caused during processing of a workflow"
10
+
11
+
12
+ class Transition(TypedDict):
13
+ """Typed Dict outlining the propertyies of a valid transition object"""
14
+
15
+ source: Optional[Union[str, list[str]]]
16
+ transition: str
17
+ dest: Optional[str]
18
+ actions: list[Callable[..., list[OdmUpdateOperation]]]
19
+
20
+
21
+ def validate_transition(transition: Transition):
22
+ "Ensure the given transition is valid"
23
+ return bool(
24
+ transition
25
+ # We want to check if a source is provided. If it is, it must have a value
26
+ # If it isn't, we'll allow this transition from any status
27
+ and ("source" not in transition or transition["source"] != "")
28
+ and transition["transition"]
29
+ # We want to check if a destination is provided. If it is, it must have a value
30
+ # If it isn't, we won't change the status of the hit
31
+ and ("dest" not in transition or transition["dest"] != "")
32
+ and isinstance(transition["actions"], list)
33
+ and all(callable(a) for a in transition["actions"])
34
+ )
35
+
36
+
37
+ class Workflow:
38
+ """A simple state-like machine that generates OdmUpdateOperations on a given transition
39
+
40
+ NOTE: This does not keep track of state, it merely provides the update operations of a transition.
41
+ """
42
+
43
+ def __init__(self, status_prop: str, transitions: list[Transition]):
44
+ self.status_prop = status_prop
45
+
46
+ if any(not validate_transition(t) for t in transitions):
47
+ raise WorkflowException("One or more transitions provided were invalid.")
48
+
49
+ self.transitions = {}
50
+ identifiers = []
51
+ for t in transitions:
52
+ if t.get("source", False) and isinstance(t["source"], list):
53
+ for s in t["source"]:
54
+ self.transitions[f'{s}{t["transition"]}'] = t
55
+ identifiers.append(f'{s}{t["transition"]}{t.get("dest", None) or ""}')
56
+ else:
57
+ self.transitions[f'{t.get("source", "") or ""}{t["transition"]}'] = t
58
+ identifiers.append(f'{t.get("source", "") or ""}{t["transition"]}{t.get("dest", "") or ""}')
59
+
60
+ if len(set(identifiers)) != len(identifiers):
61
+ raise WorkflowException("There are duplicate transitions (same source, transition and dest values).")
62
+
63
+ def transition(self, current_status: str, transition: str, **kwargs) -> list[OdmUpdateOperation]:
64
+ "Generate a list of ODM updates based on the current status and a given transition step"
65
+ _transition: Optional[Transition] = self.transitions.get(
66
+ f"{current_status}{transition}", self.transitions.get(transition, None)
67
+ )
68
+ if not _transition:
69
+ raise WorkflowException(f"Current status '{current_status}' does not allow the '{transition}' transition.")
70
+
71
+ # Check if we can actually perform this transition
72
+ source = _transition.get("source")
73
+ if source and (isinstance(source, list) and current_status not in source) and current_status != source:
74
+ raise WorkflowException(f"Current status '{current_status}' does not allow the '{transition}' transition.")
75
+
76
+ updates_dict: dict[str, OdmUpdateOperation] = {}
77
+
78
+ for action in _transition.get("actions", []):
79
+ for update in action(transition=_transition, **kwargs):
80
+ # Check if an update already exists for this property and if it's value is different
81
+ if updates_dict.get(update.key) and updates_dict[update.key].value != update.value:
82
+ raise WorkflowException(
83
+ f"Transition {transition} attempted to update the same property {update.key} with \
84
+ different values."
85
+ )
86
+
87
+ updates_dict[update.key] = update
88
+
89
+ if self.status_prop not in updates_dict and _transition.get("dest", False):
90
+ updates_dict[self.status_prop] = OdmUpdateOperation(
91
+ ESCollection.UPDATE_SET,
92
+ self.status_prop,
93
+ _transition.get("dest"),
94
+ )
95
+
96
+ self.current_status = _transition.get("dest", current_status)
97
+
98
+ return list(updates_dict.values())
99
+
100
+ def get_transitions(self, current_status: str):
101
+ "Get a list of all given transitions"
102
+ return list(
103
+ set(
104
+ [
105
+ t["transition"]
106
+ for t in self.transitions.values()
107
+ if (t["source"] and current_status in t["source"]) or not t["source"]
108
+ ]
109
+ )
110
+ )