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