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.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- 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
|
howler/helper/search.py
ADDED
|
@@ -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
|
+
)
|