howler-api 2.13.0.dev329__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 +167 -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/borealis.py +101 -0
- howler/api/v1/configs.py +55 -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 +715 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +414 -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 +144 -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/hexdump.py +48 -0
- howler/common/iprange.py +171 -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 +2327 -0
- howler/datastore/constants.py +117 -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 +214 -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 +46 -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 +1504 -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 +33 -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 +606 -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 +330 -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-2.13.0.dev329.dist-info/METADATA +71 -0
- howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
- howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
- howler_api-2.13.0.dev329.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/borealis"):
|
|
224
|
+
logger.debug("Bypassing quota limits for borealis 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
|