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,118 @@
1
+ import json
2
+ import time
3
+
4
+ from howler.remote.datatypes import get_client, retry_call
5
+
6
+ _drop_card_script = """
7
+ local set_name = ARGV[1]
8
+ local key = ARGV[2]
9
+
10
+ redis.call('srem', set_name, key)
11
+ return redis.call('scard', set_name)
12
+ """
13
+
14
+ _limited_add = """
15
+ local set_name = KEYS[1]
16
+ local key = ARGV[1]
17
+ local limit = tonumber(ARGV[2])
18
+
19
+ if redis.call('scard', set_name) < limit then
20
+ redis.call('sadd', set_name, key)
21
+ return true
22
+ end
23
+ return false
24
+ """
25
+
26
+
27
+ class Set(object):
28
+ def __init__(self, name, host=None, port=None):
29
+ self.c = get_client(host, port, False)
30
+ self.name = name
31
+ self._drop_card = self.c.register_script(_drop_card_script)
32
+ self._limited_add = self.c.register_script(_limited_add)
33
+
34
+ def __enter__(self):
35
+ return self
36
+
37
+ def __exit__(self, exc_type, exc_val, exc_tb):
38
+ self.delete()
39
+
40
+ def add(self, *values):
41
+ return retry_call(self.c.sadd, self.name, *[json.dumps(v) for v in values])
42
+
43
+ def limited_add(self, value, size_limit):
44
+ """Add a single value to the set, but only if that wouldn't make the set grow past a given size."""
45
+ return retry_call(self._limited_add, keys=[self.name], args=[json.dumps(value), size_limit])
46
+
47
+ def exist(self, value):
48
+ return retry_call(self.c.sismember, self.name, json.dumps(value))
49
+
50
+ def length(self):
51
+ return retry_call(self.c.scard, self.name)
52
+
53
+ def members(self):
54
+ return [json.loads(s) for s in retry_call(self.c.smembers, self.name)]
55
+
56
+ def rand_member(self, number=1):
57
+ result = retry_call(self.c.srandmember, self.name, number)
58
+ if not isinstance(result, list):
59
+ result = [result]
60
+
61
+ return [json.loads(entry) for entry in result]
62
+
63
+ def remove(self, *values):
64
+ return retry_call(self.c.srem, self.name, *[json.dumps(v) for v in values])
65
+
66
+ def drop(self, value):
67
+ return retry_call(self._drop_card, args=[value])
68
+
69
+ def random(self, num=None):
70
+ ret_val = retry_call(self.c.srandmember, self.name, num)
71
+ if isinstance(ret_val, list):
72
+ return [json.loads(s) for s in ret_val]
73
+ else:
74
+ return json.loads(ret_val)
75
+
76
+ def pop(self):
77
+ data = retry_call(self.c.spop, self.name)
78
+ return json.loads(data) if data else None
79
+
80
+ def pop_all(self):
81
+ return [json.loads(s) for s in retry_call(self.c.spop, self.name, self.length())]
82
+
83
+ def delete(self):
84
+ retry_call(self.c.delete, self.name)
85
+
86
+
87
+ class ExpiringSet(Set):
88
+ def __init__(self, name, ttl=86400, host=None, port=None):
89
+ super(ExpiringSet, self).__init__(name, host, port)
90
+ self.ttl = ttl
91
+ self.last_expire_time: float = 0
92
+
93
+ def _conditional_expire(self):
94
+ if self.ttl:
95
+ ctime = time.time()
96
+ if ctime > self.last_expire_time + (self.ttl / 2):
97
+ retry_call(self.c.expire, self.name, self.ttl)
98
+ self.last_expire_time = ctime
99
+
100
+ def add(self, *values):
101
+ rval = super(ExpiringSet, self).add(*values)
102
+ self._conditional_expire()
103
+ return rval
104
+
105
+ def limited_add(self, value, size_limit):
106
+ rval = super(ExpiringSet, self).limited_add(value, size_limit)
107
+ self._conditional_expire()
108
+ return rval
109
+
110
+ def members(self):
111
+ rval = super(ExpiringSet, self).members()
112
+ self._conditional_expire()
113
+ return rval
114
+
115
+ def rand_member(self, number=1):
116
+ rval = super(ExpiringSet, self).rand_member(number)
117
+ self._conditional_expire()
118
+ return rval
@@ -0,0 +1,54 @@
1
+ import redis
2
+
3
+ from howler.remote.datatypes import get_client, retry_call
4
+
5
+ begin_script = """
6
+ local t = redis.call('time')
7
+ local key = tonumber(t[1] .. string.format("%06d", t[2]))
8
+
9
+ local name = ARGV[1]
10
+ local max = tonumber(ARGV[2])
11
+ local timeout = tonumber(ARGV[3] .. "000000")
12
+
13
+ redis.call('zremrangebyscore', name, 0, key - timeout)
14
+ if redis.call('zcard', name) < max then
15
+ redis.call('zadd', name, key, key)
16
+ return true
17
+ else
18
+ return false
19
+ end
20
+ """
21
+
22
+
23
+ class UserQuotaTracker(object):
24
+ def __init__(self, prefix, timeout=120, redis=None, host=None, port=None, private=False):
25
+ self.c = redis or get_client(host, port, private)
26
+ self.bs = self.c.register_script(begin_script)
27
+ self.prefix = prefix
28
+ self.timeout = timeout
29
+
30
+ def _queue_name(self, user):
31
+ return f"{self.prefix}-{user}"
32
+
33
+ def begin(self, user, max_quota):
34
+ try:
35
+ return retry_call(self.bs, args=[self._queue_name(user), max_quota, self.timeout]) == 1
36
+ except redis.exceptions.ResponseError as er:
37
+ # TODO: This is a failsafe for upgrade purposes could be removed in a future version
38
+ if "WRONGTYPE" in str(er):
39
+ retry_call(self.c.delete, self._queue_name(user))
40
+ return retry_call(self.bs, args=[self._queue_name(user), max_quota, self.timeout]) == 1
41
+ else:
42
+ raise
43
+
44
+ def end(self, user):
45
+ """When only one item is requested, blocking is is possible."""
46
+ try:
47
+ retry_call(self.c.zpopmin, self._queue_name(user))
48
+ except redis.exceptions.ResponseError as er:
49
+ # TODO: This is a failsafe for upgrade purposes could be removed in a future version
50
+ if "WRONGTYPE" in str(er):
51
+ retry_call(self.c.delete, self._queue_name(user))
52
+ retry_call(self.c.zpopmin, self._queue_name(user))
53
+ else:
54
+ raise
@@ -0,0 +1,253 @@
1
+ import functools
2
+ import sys
3
+ from typing import Optional
4
+
5
+ import elasticapm
6
+ import requests
7
+ from flask import request
8
+ from flask import session as flsk_session
9
+ from jwt import ExpiredSignatureError
10
+ from prometheus_client import Counter
11
+
12
+ import howler.services.auth_service as auth_service
13
+ from howler.api import bad_request, forbidden, internal_error, not_found, too_many_requests, unauthorized
14
+ from howler.common.exceptions import (
15
+ AccessDeniedException,
16
+ AuthenticationException,
17
+ HowlerAttributeError,
18
+ HowlerRuntimeError,
19
+ InvalidDataException,
20
+ NotFoundException,
21
+ )
22
+ from howler.common.loader import APP_NAME
23
+ from howler.common.logging import get_logger
24
+ from howler.common.logging.audit import audit
25
+ from howler.config import AUDIT, QUOTA_TRACKER, config
26
+ from howler.odm.models.user import User
27
+
28
+ logger = get_logger(__file__)
29
+
30
+ SUCCESSFUL_ATTEMPTS = Counter(
31
+ f"{APP_NAME.replace('-', '_')}_auth_success_total",
32
+ "Successful Authentication Attempts",
33
+ )
34
+
35
+ FAILED_ATTEMPTS = Counter(
36
+ f"{APP_NAME.replace('-', '_')}_auth_fail_total",
37
+ "Failed Authentication Attempts",
38
+ ["status"],
39
+ )
40
+
41
+ XSRF_ENABLED = True
42
+
43
+
44
+ ####################################
45
+ # API Helper func and decorators
46
+ # noinspection PyPep8Naming
47
+ class api_login(object): # noqa: D101, N801
48
+ def __init__(
49
+ self,
50
+ required_type: Optional[list[str]] = None,
51
+ username_key: str = "username",
52
+ audit: bool = True,
53
+ required_priv: Optional[list[str]] = None,
54
+ required_method: Optional[list[str]] = None,
55
+ check_xsrf_token: bool = XSRF_ENABLED,
56
+ enforce_quota: bool = True,
57
+ ):
58
+ if required_priv is None:
59
+ required_priv = ["E"]
60
+
61
+ if required_type is None:
62
+ required_type = ["admin", "user"]
63
+
64
+ required_method_set: set[str]
65
+ if required_method is None:
66
+ required_method_set = {"userpass", "apikey", "internal", "oauth"}
67
+ else:
68
+ required_method_set = set(required_method)
69
+
70
+ if len(required_method_set - {"userpass", "apikey", "internal", "oauth"}) > 0:
71
+ raise HowlerAttributeError("required_method must be a subset of {userpass, apikey, internal, oauth}")
72
+
73
+ self.required_type = required_type
74
+ self.audit = audit and AUDIT
75
+ self.required_priv = required_priv
76
+ self.required_method = required_method_set
77
+ self.username_key = username_key
78
+ self.check_xsrf_token = check_xsrf_token
79
+ self.enforce_quota = enforce_quota
80
+
81
+ def __call__(self, func): # noqa: C901, D102
82
+ @functools.wraps(func)
83
+ def base(*args, **kwargs): # noqa: C901
84
+ try:
85
+ # All authorization (except impersonation) must go through the Authorization header, in one of
86
+ # four formats:
87
+ # 1. Basic user/pass authentication
88
+ # Authorization: Basic username:password (but in base64)
89
+ # 2. Basic user/apikey authentication
90
+ # Authorization: Basic username:keyname:keydata (but in base64)
91
+ # 3. Bearer internal token authentication (obtained from the login endpoint)
92
+ # Authorization: Bearer username:token
93
+ # 4. Bearer OAuth authentication (obtained from external authentication provider i.e. azure, keycloak)
94
+ # Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ (example)
95
+ authorization = request.headers.get("Authorization", None)
96
+ if not authorization:
97
+ raise AuthenticationException("No Authorization header present")
98
+ elif " " not in authorization or len(authorization.split(" ")) > 2:
99
+ raise InvalidDataException("Incorrectly formatted Authorization header")
100
+
101
+ logger.debug("Authenticating user for path %s", request.path)
102
+
103
+ [auth_type, data] = authorization.split(" ")
104
+
105
+ user = None
106
+ if auth_type == "Basic" and len(self.required_method & {"userpass", "apikey"}) > 0:
107
+ # Authenticate case (1) and (2) above
108
+ user, priv = auth_service.basic_auth(
109
+ data,
110
+ skip_apikey="apikey" not in self.required_method,
111
+ skip_password="userpass" not in self.required_method,
112
+ )
113
+ elif auth_type == "Bearer" and len(self.required_method & {"internal", "oauth"}) > 0:
114
+ # Authenticate case (3) and (4) above
115
+ try:
116
+ user, priv = auth_service.bearer_auth(
117
+ data,
118
+ skip_jwt="oauth" not in self.required_method,
119
+ skip_internal="internal" not in self.required_method,
120
+ )
121
+ except ExpiredSignatureError as e:
122
+ raise AuthenticationException("Token Expired") from e
123
+ except (requests.exceptions.ConnectionError, ConnectionError) as e:
124
+ raise HowlerRuntimeError("Failed to connect to OAuth Provider") from e
125
+ else:
126
+ raise InvalidDataException("Not a valid authentication type for this endpoint.")
127
+
128
+ if not user:
129
+ raise AuthenticationException("No authenticated user found")
130
+
131
+ # User impersonation. Basically, we want to allow a user (read: a service account) to authenticate as
132
+ # another user. A use case for this would be, for example, writing alerts to howler on behalf of a
133
+ # user, so they don't have to do it themselves.
134
+ #
135
+ # In order to do this, you provide two headers instead of one. The first header, Authorization,
136
+ # authenticates the user as usual. The second, X-Impersonating, authenticates the first user's access
137
+ # to the second user. The login format must be of type (2) above, with the added caveat that the apikey
138
+ # provided MUST be authorized for impersonation (i.e. "I" in priv == True). See validate_apikey for more
139
+ # on this.
140
+ impersonator: Optional[User] = None
141
+ impersonated_user: Optional[User] = None
142
+ impersonation = request.headers.get("X-Impersonating", None)
143
+ if impersonation:
144
+ [auth_type, data] = impersonation.split(" ")
145
+
146
+ if auth_type == "Basic":
147
+ try:
148
+ username, apikey = auth_service.decode_b64(data).split(":", 1)
149
+
150
+ (
151
+ impersonated_user,
152
+ impersonated_priv,
153
+ ) = auth_service.validate_apikey(username, apikey, user)
154
+ except AuthenticationException:
155
+ impersonated_user = None
156
+ else:
157
+ raise InvalidDataException("Not a valid authentication type for impersonation.")
158
+
159
+ # Either the they are trying to impersonate doesn't exist, or they don't have a valid key for them
160
+ if not impersonated_user:
161
+ raise AuthenticationException("No impersonated user found")
162
+
163
+ # Success!
164
+ logger.warning(
165
+ "%s is impersonating %s",
166
+ user["uname"],
167
+ impersonated_user["uname"],
168
+ )
169
+ impersonator = user
170
+ user, priv = impersonated_user, impersonated_priv
171
+
172
+ # Ensure that the provided api key allows access to this API
173
+ if not priv or not set(self.required_priv) & set(priv):
174
+ raise AccessDeniedException("You do not have access to this API.")
175
+
176
+ # Make sure the user has the correct type for this endpoint
177
+ if not set(self.required_type) & set(user["type"]):
178
+ logger.warning(
179
+ f"{user['uname']} is missing one of the types: {', '.join(self.required_type)}. "
180
+ "Cannot access {request.path}"
181
+ )
182
+ raise AccessDeniedException(
183
+ f"{request.path} requires one of the following user types: {', '.join(self.required_type)}"
184
+ )
185
+
186
+ ip = request.headers.get("X-Forwarded-For", request.remote_addr)
187
+ if "pytest" not in sys.modules:
188
+ logger.info(f"Logged in as {user['uname']} from {ip} for path {request.path}")
189
+
190
+ # If auditing is enabled, write this successful access to the audit logs
191
+ if self.audit:
192
+ audit(
193
+ args,
194
+ kwargs,
195
+ user["uname"],
196
+ user,
197
+ func,
198
+ impersonator=impersonator["uname"] if impersonator else None,
199
+ )
200
+ except InvalidDataException as e:
201
+ FAILED_ATTEMPTS.labels("400").inc()
202
+ return bad_request(err=e.message)
203
+ except AuthenticationException as e:
204
+ FAILED_ATTEMPTS.labels("401").inc()
205
+ return unauthorized(err=e.message)
206
+ except AccessDeniedException as e:
207
+ FAILED_ATTEMPTS.labels("403").inc()
208
+ return forbidden(err=e.message)
209
+ except NotFoundException as e:
210
+ FAILED_ATTEMPTS.labels("404").inc()
211
+ return not_found(err=e.message)
212
+ except HowlerRuntimeError as e:
213
+ FAILED_ATTEMPTS.labels("500").inc()
214
+ return internal_error(err=e.message)
215
+
216
+ if config.core.metrics.apm_server.server_url is not None:
217
+ elasticapm.set_user_context(
218
+ username=user.get("name", None),
219
+ email=user.get("email", None),
220
+ user_id=user.get("uname", None),
221
+ )
222
+
223
+ if request.path.startswith("/api/v1/clue"):
224
+ logger.debug("Bypassing quota limits for clue enrichment")
225
+ elif self.enforce_quota:
226
+ # Check current user quota
227
+ flsk_session["quota_user"] = user["uname"]
228
+ flsk_session["quota_set"] = True
229
+
230
+ quota = user.get("api_quota", 25)
231
+ if not QUOTA_TRACKER.begin(user["uname"], quota):
232
+ if config.ui.enforce_quota:
233
+ logger.warning(f"{user['uname']} was prevented from using the api due to exceeded quota.")
234
+ FAILED_ATTEMPTS.labels("429").inc()
235
+ return too_many_requests(err=f"You've exceeded your maximum quota of {quota}")
236
+ else:
237
+ logger.debug(f"Quota of {quota} exceeded for user {user['uname']}.")
238
+ else:
239
+ logger.debug(f"Quota not enforced for {user['uname']}")
240
+
241
+ # Save user data in kwargs for future reference in the wrapped method
242
+ kwargs["user"] = user
243
+
244
+ SUCCESSFUL_ATTEMPTS.inc()
245
+ return func(*args, **kwargs)
246
+
247
+ base.protected = True
248
+ base.required_type = self.required_type
249
+ base.audit = self.audit
250
+ base.required_priv = self.required_priv
251
+ base.required_method = self.required_method
252
+ base.check_xsrf_token = self.check_xsrf_token
253
+ return base
@@ -0,0 +1,108 @@
1
+ import functools
2
+ import json
3
+ import uuid
4
+ from typing import Optional
5
+
6
+ from flask import request
7
+ from jwt import InvalidTokenError
8
+
9
+ import howler.services.auth_service as auth_service
10
+ from howler.api import forbidden, ok, unauthorized
11
+ from howler.common.exceptions import AuthenticationException
12
+ from howler.common.logging import get_logger
13
+ from howler.helper.ws import ConnectionClosed, Server
14
+
15
+ logger = get_logger(__file__)
16
+
17
+
18
+ def ws_response(type, data={}, error=False, status=200, message=""):
19
+ "Create a formatted websocket response"
20
+ return json.dumps({"error": error, "status": status, "message": message, "type": type, **data})
21
+
22
+
23
+ def websocket_auth(required_type: Optional[list[str]] = None, required_priv: Optional[list[str]] = None):
24
+ """Authentication for a new websocket connection.
25
+
26
+ Args:
27
+ required_type (Optional[list[str]], optional): The type required to access this websocket endpoint.
28
+ Defaults to None.
29
+ required_priv (Optional[list[str]], optional): The privileges required to access this websocket endpoint.
30
+ Defaults to None.
31
+ """
32
+ if required_type is None:
33
+ required_type = ["user"]
34
+
35
+ if required_priv is None:
36
+ required_priv = ["R", "W"]
37
+
38
+ def wrapper(f):
39
+ @functools.wraps(f)
40
+ def auth(*args, **kwargs):
41
+ try:
42
+ ws_id = str(uuid.uuid4())
43
+ ws = Server(request.environ, ping_interval=5)
44
+
45
+ auth_header = ws.receive()
46
+
47
+ user, privs = auth_service.bearer_auth(auth_header)
48
+
49
+ if not user or not privs:
50
+ raise AuthenticationException() # noqa: TRY301
51
+
52
+ if not set(required_priv) & set(privs):
53
+ logger.warning(f"{ws_id}: Authentication header is invalid")
54
+ ws.close(
55
+ 1008,
56
+ ws_response(
57
+ "error",
58
+ error=True,
59
+ status=403,
60
+ message="The method you've used to login does not give you access to this API.",
61
+ ),
62
+ )
63
+ return forbidden()
64
+
65
+ logger.info(f"{ws_id} authenticated as {user['uname']}")
66
+ ws.send(
67
+ ws_response(
68
+ "info",
69
+ {
70
+ "message": f"Listener authenticated as {user['uname']}",
71
+ "id": ws_id,
72
+ "username": user["uname"],
73
+ },
74
+ )
75
+ )
76
+
77
+ f(ws, *args, ws_id=ws_id, username=user["uname"], privs=privs, **kwargs)
78
+ except ConnectionClosed:
79
+ logger.info(f"{ws_id}: Client closed connection")
80
+ except (
81
+ AuthenticationException,
82
+ ValueError,
83
+ InvalidTokenError,
84
+ ):
85
+ logger.warning(f"{ws_id}: Authentication header is invalid")
86
+ ws.close(
87
+ 1008,
88
+ ws_response(
89
+ "error",
90
+ error=True,
91
+ status=401,
92
+ message="Authentication header is invalid.",
93
+ ),
94
+ )
95
+ return unauthorized()
96
+ finally:
97
+ try:
98
+ ws.close()
99
+ except Exception as e:
100
+ logger.debug("Exception on WS close: %s", str(e))
101
+ finally:
102
+ ws.connected = False
103
+
104
+ return ok()
105
+
106
+ return auth
107
+
108
+ return wrapper