howler-api 3.0.0.dev374__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of howler-api might be problematic. Click here for more details.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from os import environ
|
|
7
|
+
from typing import Any, Optional, cast
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import elasticsearch
|
|
11
|
+
import elasticsearch.client
|
|
12
|
+
|
|
13
|
+
from howler.datastore.collection import ESCollection
|
|
14
|
+
from howler.datastore.exceptions import DataStoreException
|
|
15
|
+
from howler.odm.models.config import Config
|
|
16
|
+
from howler.odm.models.config import config as _config
|
|
17
|
+
|
|
18
|
+
TRANSPORT_TIMEOUT = int(environ.get("HWL_DATASTORE_TRANSPORT_TIMEOUT", "10"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ESStore(object):
|
|
22
|
+
"""Elasticsearch multi-index implementation of the ResultStore interface."""
|
|
23
|
+
|
|
24
|
+
DEFAULT_SORT = "id asc"
|
|
25
|
+
DATE_FORMAT = {
|
|
26
|
+
"NOW": "now",
|
|
27
|
+
"YEAR": "y",
|
|
28
|
+
"MONTH": "M",
|
|
29
|
+
"WEEK": "w",
|
|
30
|
+
"DAY": "d",
|
|
31
|
+
"HOUR": "h",
|
|
32
|
+
"MINUTE": "m",
|
|
33
|
+
"SECOND": "s",
|
|
34
|
+
"MILLISECOND": "ms",
|
|
35
|
+
"MICROSECOND": "micros",
|
|
36
|
+
"NANOSECOND": "nanos",
|
|
37
|
+
"SEPARATOR": "||",
|
|
38
|
+
"DATE_END": "Z",
|
|
39
|
+
}
|
|
40
|
+
DATEMATH_MAP = {
|
|
41
|
+
"NOW": "now",
|
|
42
|
+
"YEAR": "y",
|
|
43
|
+
"MONTH": "M",
|
|
44
|
+
"WEEK": "w",
|
|
45
|
+
"DAY": "d",
|
|
46
|
+
"HOUR": "h",
|
|
47
|
+
"MINUTE": "m",
|
|
48
|
+
"SECOND": "s",
|
|
49
|
+
"DATE_END": "Z||",
|
|
50
|
+
}
|
|
51
|
+
ID = "id"
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: Optional[Config] = None, archive_access=True):
|
|
54
|
+
if not config:
|
|
55
|
+
config = _config
|
|
56
|
+
|
|
57
|
+
self._apikey: Optional[tuple[str, str]] = None
|
|
58
|
+
self._username: Optional[str] = None
|
|
59
|
+
self._password: Optional[str] = None
|
|
60
|
+
self._hosts = []
|
|
61
|
+
|
|
62
|
+
for host in config.datastore.hosts:
|
|
63
|
+
self._hosts.append(str(host))
|
|
64
|
+
if os.getenv(f"{host.name.upper()}_HOST_APIKEY_ID", None) is not None:
|
|
65
|
+
self._apikey = (
|
|
66
|
+
os.environ[f"{host.name.upper()}_HOST_APIKEY_ID"],
|
|
67
|
+
os.environ[f"{host.name.upper()}_HOST_APIKEY_SECRET"],
|
|
68
|
+
)
|
|
69
|
+
elif os.getenv(f"{host.name.upper()}_HOST_USERNAME") is not None:
|
|
70
|
+
self._username = os.environ[f"{host.name.upper()}_HOST_USERNAME"]
|
|
71
|
+
self._password = os.environ[f"{host.name.upper()}_HOST_PASSWORD"]
|
|
72
|
+
|
|
73
|
+
self._closed = False
|
|
74
|
+
self._collections: dict[str, ESCollection] = {}
|
|
75
|
+
self._models: dict[str, Any] = {}
|
|
76
|
+
self.validate = True
|
|
77
|
+
|
|
78
|
+
tracer = logging.getLogger("elasticsearch")
|
|
79
|
+
tracer.setLevel(logging.CRITICAL)
|
|
80
|
+
|
|
81
|
+
if self._apikey is not None:
|
|
82
|
+
self.client = elasticsearch.Elasticsearch(
|
|
83
|
+
hosts=self._hosts, # type: ignore
|
|
84
|
+
api_key=self._apikey,
|
|
85
|
+
max_retries=0,
|
|
86
|
+
request_timeout=TRANSPORT_TIMEOUT,
|
|
87
|
+
)
|
|
88
|
+
elif self._username is not None and self._password is not None:
|
|
89
|
+
self.client = elasticsearch.Elasticsearch(
|
|
90
|
+
hosts=self._hosts, # type: ignore
|
|
91
|
+
basic_auth=(self._username, self._password),
|
|
92
|
+
max_retries=0,
|
|
93
|
+
request_timeout=TRANSPORT_TIMEOUT,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
self.client = elasticsearch.Elasticsearch(
|
|
97
|
+
hosts=self._hosts, # type: ignore
|
|
98
|
+
max_retries=0,
|
|
99
|
+
request_timeout=TRANSPORT_TIMEOUT,
|
|
100
|
+
)
|
|
101
|
+
self.eql = elasticsearch.client.EqlClient(self.client)
|
|
102
|
+
self.archive_access = archive_access
|
|
103
|
+
self.url_path = "elastic"
|
|
104
|
+
|
|
105
|
+
def __enter__(self):
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, ex_type, exc_val, exc_tb):
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
def __str__(self):
|
|
112
|
+
return "{0} - {1}".format(self.__class__.__name__, self._hosts)
|
|
113
|
+
|
|
114
|
+
def __getattr__(self, name) -> ESCollection:
|
|
115
|
+
if not self.validate:
|
|
116
|
+
return ESCollection(self, name, model_class=self._models[name], validate=self.validate)
|
|
117
|
+
|
|
118
|
+
if name not in self._collections:
|
|
119
|
+
self._collections[name] = ESCollection(self, name, model_class=self._models[name], validate=self.validate)
|
|
120
|
+
|
|
121
|
+
return self._collections[name]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def now(self):
|
|
125
|
+
return self.DATE_FORMAT["NOW"]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def ms(self):
|
|
129
|
+
return self.DATE_FORMAT["MILLISECOND"]
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def us(self):
|
|
133
|
+
return self.DATE_FORMAT["MICROSECOND"]
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def ns(self):
|
|
137
|
+
return self.DATE_FORMAT["NANOSECOND"]
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def year(self):
|
|
141
|
+
return self.DATE_FORMAT["YEAR"]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def month(self):
|
|
145
|
+
return self.DATE_FORMAT["MONTH"]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def week(self):
|
|
149
|
+
return self.DATE_FORMAT["WEEK"]
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def day(self):
|
|
153
|
+
return self.DATE_FORMAT["DAY"]
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def hour(self):
|
|
157
|
+
return self.DATE_FORMAT["HOUR"]
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def minute(self):
|
|
161
|
+
return self.DATE_FORMAT["MINUTE"]
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def second(self):
|
|
165
|
+
return self.DATE_FORMAT["SECOND"]
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def date_separator(self):
|
|
169
|
+
return self.DATE_FORMAT["SEPARATOR"]
|
|
170
|
+
|
|
171
|
+
def connection_reset(self):
|
|
172
|
+
self.client = elasticsearch.Elasticsearch(
|
|
173
|
+
hosts=self._hosts, # type: ignore
|
|
174
|
+
api_key=self._apikey,
|
|
175
|
+
max_retries=0,
|
|
176
|
+
request_timeout=TRANSPORT_TIMEOUT,
|
|
177
|
+
)
|
|
178
|
+
self.eql = elasticsearch.client.EqlClient(self.client)
|
|
179
|
+
|
|
180
|
+
def close(self):
|
|
181
|
+
self._closed = True
|
|
182
|
+
# Flatten the client object so that attempts to access without reconnecting errors hard
|
|
183
|
+
# But 'cast' it so that mypy and other linters don't think that its normal for client to be None
|
|
184
|
+
self.client = cast(elasticsearch.Elasticsearch, None)
|
|
185
|
+
self.eql = cast(elasticsearch.client.EqlClient, None)
|
|
186
|
+
|
|
187
|
+
def get_hosts(self, safe=False):
|
|
188
|
+
if not safe:
|
|
189
|
+
return self._hosts
|
|
190
|
+
else:
|
|
191
|
+
out = []
|
|
192
|
+
for h in self._hosts:
|
|
193
|
+
parsed = urlparse(h)
|
|
194
|
+
out.append(parsed.hostname or parsed.path)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def get_models(self):
|
|
198
|
+
return self._models
|
|
199
|
+
|
|
200
|
+
def is_closed(self):
|
|
201
|
+
return self._closed
|
|
202
|
+
|
|
203
|
+
def ping(self):
|
|
204
|
+
return self.client.ping()
|
|
205
|
+
|
|
206
|
+
def register(self, name: str, model_class=None):
|
|
207
|
+
name_match = re.match(r"[a-z0-9_]*", name)
|
|
208
|
+
if not name_match or name_match.string != name:
|
|
209
|
+
raise DataStoreException(
|
|
210
|
+
"Invalid characters in model name. " "You can only use lower case letters, numbers and underscores."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self._models[name] = model_class
|
|
214
|
+
|
|
215
|
+
def to_pydatemath(self, value):
|
|
216
|
+
replace_list = [
|
|
217
|
+
(self.now, self.DATEMATH_MAP["NOW"]),
|
|
218
|
+
(self.year, self.DATEMATH_MAP["YEAR"]),
|
|
219
|
+
(self.month, self.DATEMATH_MAP["MONTH"]),
|
|
220
|
+
(self.week, self.DATEMATH_MAP["WEEK"]),
|
|
221
|
+
(self.day, self.DATEMATH_MAP["DAY"]),
|
|
222
|
+
(self.hour, self.DATEMATH_MAP["HOUR"]),
|
|
223
|
+
(self.minute, self.DATEMATH_MAP["MINUTE"]),
|
|
224
|
+
(self.second, self.DATEMATH_MAP["SECOND"]),
|
|
225
|
+
(self.DATE_FORMAT["DATE_END"], self.DATEMATH_MAP["DATE_END"]),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for x in replace_list:
|
|
229
|
+
value = value.replace(*x)
|
|
230
|
+
|
|
231
|
+
return value
|
|
File without changes
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
from howler.common.exceptions import HowlerNotImplementedError, HowlerValueError
|
|
4
|
+
from howler.common.logging import get_logger
|
|
5
|
+
from howler.datastore.constants import ANALYZER_MAPPING, NORMALIZER_MAPPING, TYPE_MAPPING
|
|
6
|
+
from howler.odm import (
|
|
7
|
+
Any,
|
|
8
|
+
Boolean,
|
|
9
|
+
Classification,
|
|
10
|
+
Compound,
|
|
11
|
+
Date,
|
|
12
|
+
FlattenedObject,
|
|
13
|
+
Float,
|
|
14
|
+
Integer,
|
|
15
|
+
Json,
|
|
16
|
+
Keyword,
|
|
17
|
+
List,
|
|
18
|
+
Long,
|
|
19
|
+
Mapping,
|
|
20
|
+
Optional,
|
|
21
|
+
Text,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__file__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_mapping(field_data, prefix=None, allow_refuse_implicit=True):
|
|
28
|
+
"""The mapping for Elasticsearch based on a python model object."""
|
|
29
|
+
prefix = prefix or []
|
|
30
|
+
mappings = {}
|
|
31
|
+
dynamic = []
|
|
32
|
+
|
|
33
|
+
def set_mapping(temp_field, body):
|
|
34
|
+
body["index"] = temp_field.index
|
|
35
|
+
if body.get("type", "text") != "text":
|
|
36
|
+
body["doc_values"] = temp_field.index
|
|
37
|
+
if temp_field.copyto:
|
|
38
|
+
if len(field.copyto) > 1:
|
|
39
|
+
logger.warning("copyto field larger than 1, only using first entry")
|
|
40
|
+
body["copy_to"] = temp_field.copyto[0]
|
|
41
|
+
|
|
42
|
+
return body
|
|
43
|
+
|
|
44
|
+
# Fill in the sections
|
|
45
|
+
for field in field_data:
|
|
46
|
+
path = prefix + ([field.name] if field.name else [])
|
|
47
|
+
name = ".".join(path)
|
|
48
|
+
|
|
49
|
+
if isinstance(field, Classification):
|
|
50
|
+
mappings[name.strip(".")] = set_mapping(field, {"type": TYPE_MAPPING[field.__class__.__name__]})
|
|
51
|
+
if "." not in name:
|
|
52
|
+
mappings.update(
|
|
53
|
+
{
|
|
54
|
+
"__access_lvl__": {"type": "integer", "index": True},
|
|
55
|
+
"__access_req__": {"type": "keyword", "index": True},
|
|
56
|
+
"__access_grp1__": {"type": "keyword", "index": True},
|
|
57
|
+
"__access_grp2__": {"type": "keyword", "index": True},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
elif isinstance(field, (Boolean, Integer, Float, Text, Long)):
|
|
62
|
+
mappings[name.strip(".")] = set_mapping(field, {"type": TYPE_MAPPING[field.__class__.__name__]})
|
|
63
|
+
|
|
64
|
+
elif field.__class__ in ANALYZER_MAPPING:
|
|
65
|
+
mappings[name.strip(".")] = set_mapping(
|
|
66
|
+
field,
|
|
67
|
+
{
|
|
68
|
+
"type": TYPE_MAPPING[field.__class__.__name__],
|
|
69
|
+
"analyzer": ANALYZER_MAPPING[field.__class__],
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
elif isinstance(field, (Json, Keyword)):
|
|
74
|
+
es_data_type = TYPE_MAPPING[field.__class__.__name__]
|
|
75
|
+
data: dict[str, Union[str, int]] = {"type": es_data_type}
|
|
76
|
+
if es_data_type == "keyword":
|
|
77
|
+
data["ignore_above"] = 8191 # The maximum always safe value in elasticsearch
|
|
78
|
+
if field.__class__ in NORMALIZER_MAPPING:
|
|
79
|
+
data["normalizer"] = NORMALIZER_MAPPING[field.__class__] # type: ignore
|
|
80
|
+
mappings[name.strip(".")] = set_mapping(field, data)
|
|
81
|
+
|
|
82
|
+
elif isinstance(field, Date):
|
|
83
|
+
mappings[name.strip(".")] = set_mapping(
|
|
84
|
+
field,
|
|
85
|
+
{
|
|
86
|
+
"type": TYPE_MAPPING[field.__class__.__name__],
|
|
87
|
+
"format": "date_optional_time||epoch_millis",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
elif isinstance(field, FlattenedObject):
|
|
92
|
+
if not field.index or isinstance(field.child_type, Any):
|
|
93
|
+
mappings[name.strip(".")] = {"type": "object", "enabled": False}
|
|
94
|
+
else:
|
|
95
|
+
dynamic.extend(
|
|
96
|
+
build_templates(
|
|
97
|
+
f"{name}.*",
|
|
98
|
+
field.child_type,
|
|
99
|
+
nested_template=True,
|
|
100
|
+
index=field.index,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
elif isinstance(field, List):
|
|
105
|
+
temp_mappings, temp_dynamic = build_mapping([field.child_type], prefix=path, allow_refuse_implicit=False)
|
|
106
|
+
mappings.update(temp_mappings)
|
|
107
|
+
dynamic.extend(temp_dynamic)
|
|
108
|
+
|
|
109
|
+
elif isinstance(field, Optional):
|
|
110
|
+
temp_mappings, temp_dynamic = build_mapping([field.child_type], prefix=prefix, allow_refuse_implicit=False)
|
|
111
|
+
mappings.update(temp_mappings)
|
|
112
|
+
dynamic.extend(temp_dynamic)
|
|
113
|
+
|
|
114
|
+
elif isinstance(field, Compound):
|
|
115
|
+
temp_mappings, temp_dynamic = build_mapping(
|
|
116
|
+
field.fields().values(), prefix=path, allow_refuse_implicit=False
|
|
117
|
+
)
|
|
118
|
+
mappings.update(temp_mappings)
|
|
119
|
+
dynamic.extend(temp_dynamic)
|
|
120
|
+
|
|
121
|
+
elif isinstance(field, Mapping):
|
|
122
|
+
if not field.index or isinstance(field.child_type, Any):
|
|
123
|
+
mappings[name.strip(".")] = {"type": "object", "enabled": False}
|
|
124
|
+
else:
|
|
125
|
+
dynamic.extend(build_templates(f"{name}.*", field.child_type, index=field.index))
|
|
126
|
+
|
|
127
|
+
elif isinstance(field, Any):
|
|
128
|
+
if field.index:
|
|
129
|
+
raise HowlerValueError(f"Any may not be indexed: {name}")
|
|
130
|
+
|
|
131
|
+
mappings[name.strip(".")] = {
|
|
132
|
+
"type": "keyword",
|
|
133
|
+
"index": False,
|
|
134
|
+
"doc_values": False,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
else:
|
|
138
|
+
raise HowlerNotImplementedError(f"Unknown type for elasticsearch schema: {field.__class__}")
|
|
139
|
+
|
|
140
|
+
# The final template must match everything and disable indexing
|
|
141
|
+
# this effectively disables dynamic indexing EXCEPT for the templates
|
|
142
|
+
# we have defined
|
|
143
|
+
if not dynamic and allow_refuse_implicit:
|
|
144
|
+
# We cannot use the dynamic type matching if others are in play because they conflict with each other
|
|
145
|
+
# TODO: Find a way to make them work together.
|
|
146
|
+
dynamic.append(
|
|
147
|
+
{
|
|
148
|
+
"refuse_all_implicit_mappings": {
|
|
149
|
+
"match": "*",
|
|
150
|
+
"mapping": {
|
|
151
|
+
"index": False,
|
|
152
|
+
"ignore_malformed": True,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return mappings, dynamic
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_templates(name, field, nested_template=False, index=True) -> list:
|
|
162
|
+
if isinstance(field, (Keyword, Boolean, Integer, Float, Text, Json)):
|
|
163
|
+
if nested_template:
|
|
164
|
+
main_template = {"match": f"{name}", "mapping": {"type": "nested"}}
|
|
165
|
+
|
|
166
|
+
return [{f"nested_{name}": main_template}]
|
|
167
|
+
else:
|
|
168
|
+
field_template = {
|
|
169
|
+
"path_match": name,
|
|
170
|
+
"mapping": {
|
|
171
|
+
"type": TYPE_MAPPING[field.__class__.__name__],
|
|
172
|
+
"index": field.index,
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if field.copyto:
|
|
177
|
+
if len(field.copyto) > 1:
|
|
178
|
+
logger.warning("copyto field larger than 1, only using first entry")
|
|
179
|
+
field_template["mapping"]["copy_to"] = field.copyto[0]
|
|
180
|
+
|
|
181
|
+
return [{f"{name}_tpl": field_template}]
|
|
182
|
+
|
|
183
|
+
elif isinstance(field, Any) or not index:
|
|
184
|
+
field_template = {
|
|
185
|
+
"path_match": name,
|
|
186
|
+
"mapping": {"type": "keyword", "index": False},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if field.index:
|
|
190
|
+
raise HowlerValueError(f"Mapping to Any may not be indexed: {name}")
|
|
191
|
+
return [{f"{name}_tpl": field_template}]
|
|
192
|
+
|
|
193
|
+
elif isinstance(field, (Mapping, List)):
|
|
194
|
+
temp_name = name
|
|
195
|
+
if field.name:
|
|
196
|
+
temp_name = f"{name}.{field.name}"
|
|
197
|
+
return build_templates(temp_name, field.child_type, nested_template=True)
|
|
198
|
+
|
|
199
|
+
elif isinstance(field, Compound):
|
|
200
|
+
temp_name = name
|
|
201
|
+
if field.name:
|
|
202
|
+
temp_name = f"{name}.{field.name}"
|
|
203
|
+
|
|
204
|
+
out = []
|
|
205
|
+
for sub_name, sub_field in field.fields().items():
|
|
206
|
+
sub_name = f"{temp_name}.{sub_name}"
|
|
207
|
+
out.extend(build_templates(sub_name, sub_field))
|
|
208
|
+
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
elif isinstance(field, Optional):
|
|
212
|
+
return build_templates(name, field.child_type, nested_template=nested_template)
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
raise HowlerNotImplementedError(f"Unknown type for elasticsearch dynamic mapping: {field.__class__}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
default_index = {
|
|
2
|
+
"settings": {
|
|
3
|
+
"analysis": {
|
|
4
|
+
"filter": {
|
|
5
|
+
"text_ws_dsplit": {
|
|
6
|
+
"type": "pattern_replace",
|
|
7
|
+
"pattern": r"(\.)",
|
|
8
|
+
"replacement": " ",
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"analyzer": {
|
|
12
|
+
"string_ci": {
|
|
13
|
+
"type": "custom",
|
|
14
|
+
"tokenizer": "keyword",
|
|
15
|
+
"filter": ["lowercase"],
|
|
16
|
+
},
|
|
17
|
+
"text_fuzzy": {
|
|
18
|
+
"type": "pattern",
|
|
19
|
+
"pattern": r"\s*:\s*",
|
|
20
|
+
"lowercase": False,
|
|
21
|
+
},
|
|
22
|
+
"text_whitespace": {"type": "whitespace"},
|
|
23
|
+
"text_ws_dsplit": {
|
|
24
|
+
"type": "custom",
|
|
25
|
+
"tokenizer": "whitespace",
|
|
26
|
+
"filters": ["text_ws_dsplit"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"normalizer": {
|
|
30
|
+
"lowercase_normalizer": {
|
|
31
|
+
"type": "custom",
|
|
32
|
+
"char_filter": [],
|
|
33
|
+
"filter": ["lowercase"],
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"mappings": {},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
default_mapping = {
|
|
42
|
+
"dynamic": True,
|
|
43
|
+
"properties": {
|
|
44
|
+
"__text__": {"type": "text"},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
default_dynamic_strings = {
|
|
49
|
+
"strings_as_keywords": {
|
|
50
|
+
"match_mapping_type": "string",
|
|
51
|
+
"mapping": {"type": "keyword", "ignore_above": 8191},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
default_dynamic_templates = [
|
|
56
|
+
{"int": {"path_match": "*_i", "mapping": {"type": "integer", "store": True}}},
|
|
57
|
+
{"ints": {"path_match": "*_is", "mapping": {"type": "integer", "store": True}}},
|
|
58
|
+
{"long": {"path_match": "*_l", "mapping": {"type": "long", "store": True}}},
|
|
59
|
+
{"longs": {"path_match": "*_ls", "mapping": {"type": "long", "store": True}}},
|
|
60
|
+
{"double": {"path_match": "*_d", "mapping": {"type": "float", "store": True}}},
|
|
61
|
+
{"doubles": {"path_match": "*_ds", "mapping": {"type": "float", "store": True}}},
|
|
62
|
+
{"float": {"path_match": "*_f", "mapping": {"type": "float", "store": True}}},
|
|
63
|
+
{"floats": {"path_match": "*_fs", "mapping": {"type": "float", "store": True}}},
|
|
64
|
+
{"string": {"path_match": "*_s", "mapping": {"type": "keyword", "store": True}}},
|
|
65
|
+
{"strings": {"path_match": "*_ss", "mapping": {"type": "keyword", "store": True}}},
|
|
66
|
+
{"text": {"path_match": "*_t", "mapping": {"type": "text", "store": True}}},
|
|
67
|
+
{"texts": {"path_match": "*_ts", "mapping": {"type": "text", "store": True}}},
|
|
68
|
+
{"boolean": {"path_match": "*_b", "mapping": {"type": "boolean", "store": True}}},
|
|
69
|
+
{"booleans": {"path_match": "*_bs", "mapping": {"type": "boolean", "store": True}}},
|
|
70
|
+
{
|
|
71
|
+
"date": {
|
|
72
|
+
"path_match": "*_dt",
|
|
73
|
+
"mapping": {
|
|
74
|
+
"type": "date",
|
|
75
|
+
"format": "date_optional_time||epoch_millis",
|
|
76
|
+
"store": True,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"date": {
|
|
82
|
+
"path_match": "*_dts",
|
|
83
|
+
"mapping": {
|
|
84
|
+
"type": "date",
|
|
85
|
+
"format": "date_optional_time||epoch_millis",
|
|
86
|
+
"store": True,
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any, TypedDict, Union
|
|
2
|
+
|
|
3
|
+
from howler.odm import Model
|
|
4
|
+
from howler.odm.models.hit import Hit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SearchResult(TypedDict):
|
|
8
|
+
"""Search Results for an Elastic search query.
|
|
9
|
+
|
|
10
|
+
TypedDict's are used in the same way as a normal Dict, but provides typing for InteliSense
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
offset: int
|
|
14
|
+
rows: int
|
|
15
|
+
total: int
|
|
16
|
+
items: list[Union[Model, dict[str, Any]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HitSearchResult(SearchResult):
|
|
20
|
+
"""Hit specific typing for Elastic search query"""
|
|
21
|
+
|
|
22
|
+
items: list[Union[Hit, dict[str, Any]]] # type: ignore[misc]
|
howler/error.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from sys import exc_info
|
|
2
|
+
from traceback import format_tb
|
|
3
|
+
|
|
4
|
+
from flask import Blueprint, request
|
|
5
|
+
from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized
|
|
6
|
+
|
|
7
|
+
from howler.api import bad_request, forbidden, internal_error, not_found, unauthorized
|
|
8
|
+
from howler.common.exceptions import AccessDeniedException, AuthenticationException
|
|
9
|
+
from howler.common.logging import get_logger, log_with_traceback
|
|
10
|
+
from howler.common.logging.audit import AUDIT
|
|
11
|
+
from howler.config import config
|
|
12
|
+
|
|
13
|
+
errors = Blueprint("errors", __name__)
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__file__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
######################################
|
|
19
|
+
# Custom Error page
|
|
20
|
+
@errors.app_errorhandler(400)
|
|
21
|
+
def handle_400(e):
|
|
22
|
+
"""Handle bad request errors"""
|
|
23
|
+
if isinstance(e, BadRequest):
|
|
24
|
+
error_message = "No data block provided or data block not in JSON format.'"
|
|
25
|
+
else:
|
|
26
|
+
error_message = str(e)
|
|
27
|
+
return bad_request(err=error_message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@errors.app_errorhandler(401)
|
|
31
|
+
def handle_401(e):
|
|
32
|
+
"""Handle unauthorized errors"""
|
|
33
|
+
if isinstance(e, Unauthorized):
|
|
34
|
+
msg = e.description
|
|
35
|
+
else:
|
|
36
|
+
msg = str(e)
|
|
37
|
+
|
|
38
|
+
data = {
|
|
39
|
+
"oauth_providers": [name for name in config.auth.oauth.providers.keys()],
|
|
40
|
+
"allow_userpass_login": config.auth.internal.enabled,
|
|
41
|
+
}
|
|
42
|
+
res = unauthorized(data, err=msg)
|
|
43
|
+
res.set_cookie("XSRF-TOKEN", "", max_age=0, secure=True, httponly=True, samesite="Strict")
|
|
44
|
+
return res
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@errors.app_errorhandler(403)
|
|
48
|
+
def handle_403(e):
|
|
49
|
+
"""Handle bad forbidden errors"""
|
|
50
|
+
if isinstance(e, Forbidden):
|
|
51
|
+
error_message = e.description
|
|
52
|
+
else:
|
|
53
|
+
error_message = str(e)
|
|
54
|
+
|
|
55
|
+
trace = exc_info()[2]
|
|
56
|
+
if AUDIT:
|
|
57
|
+
uname = "(None)"
|
|
58
|
+
ip = request.remote_addr
|
|
59
|
+
|
|
60
|
+
log_with_traceback(trace, f"Access Denied. (U:{uname} - IP:{ip}) [{error_message}]", audit=True)
|
|
61
|
+
|
|
62
|
+
config_block = {
|
|
63
|
+
"auth": {
|
|
64
|
+
"allow_apikeys": config.auth.allow_apikeys,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return forbidden(config_block, err=f"Access Denied ({request.path}) [{error_message}]")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@errors.app_errorhandler(404)
|
|
71
|
+
def handle_404(_):
|
|
72
|
+
"""Handle not found errors"""
|
|
73
|
+
return not_found(err=f"Api does not exist ({request.path})")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@errors.app_errorhandler(500)
|
|
77
|
+
def handle_500(e):
|
|
78
|
+
"""Handle internal server errors"""
|
|
79
|
+
if isinstance(e.original_exception, AccessDeniedException):
|
|
80
|
+
return handle_403(e.original_exception)
|
|
81
|
+
|
|
82
|
+
if isinstance(e.original_exception, AuthenticationException):
|
|
83
|
+
return handle_401(e.original_exception)
|
|
84
|
+
|
|
85
|
+
oe = e.original_exception or e
|
|
86
|
+
|
|
87
|
+
trace = exc_info()[2]
|
|
88
|
+
log_with_traceback(trace, "Exception", is_exception=True)
|
|
89
|
+
|
|
90
|
+
message = "".join(["\n"] + format_tb(exc_info()[2]) + ["%s: %s\n" % (oe.__class__.__name__, str(oe))]).rstrip("\n")
|
|
91
|
+
return internal_error(err=message)
|
|
File without changes
|