howler-api 3.0.0.dev374__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of howler-api might be problematic. Click here for more details.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
howler/security/utils.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import elasticapm
|
|
8
|
+
from passlib.hash import bcrypt
|
|
9
|
+
|
|
10
|
+
from howler.config import config
|
|
11
|
+
|
|
12
|
+
UPPERCASE = r"[A-Z]"
|
|
13
|
+
LOWERCASE = r"[a-z]"
|
|
14
|
+
NUMBER = r"[0-9]"
|
|
15
|
+
SPECIAL = r'[ !#$@%&\'()*+,-./[\\\]^_`{|}~"]'
|
|
16
|
+
PASS_BASIC = (
|
|
17
|
+
[chr(x + 65) for x in range(26)]
|
|
18
|
+
+ [chr(x + 97) for x in range(26)]
|
|
19
|
+
+ [str(x) for x in range(10)]
|
|
20
|
+
+ ["!", "@", "$", "^", "?", "&", "*", "(", ")"]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_random_secret(length: int = 25) -> str:
|
|
25
|
+
"""Generate a random secret
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
length (int, optional): The length of the secret. Defaults to 25.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The random secret
|
|
32
|
+
"""
|
|
33
|
+
return base64.b32encode(os.urandom(length)).decode("UTF-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_password_hash(password: Optional[str]) -> Optional[str]:
|
|
37
|
+
"""Get a bcrypt hash of the password
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
password (Optional[str]): The password to hash
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
str: The hash of the password
|
|
44
|
+
"""
|
|
45
|
+
if password is None or len(password) == 0:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
return bcrypt.hash(password)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@elasticapm.capture_span(span_type="authentication")
|
|
52
|
+
def verify_password(password: str, pw_hash: str):
|
|
53
|
+
"""Use bcrypt to verify a user's password against the hash"""
|
|
54
|
+
try:
|
|
55
|
+
return bcrypt.verify(password, pw_hash)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
except TypeError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_password_requirement_message(
|
|
63
|
+
lower: bool = True,
|
|
64
|
+
upper: bool = True,
|
|
65
|
+
number: bool = False,
|
|
66
|
+
special: bool = False,
|
|
67
|
+
min_length: int = 12,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Get a custom password requirement message based on the configuration values
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
lower (bool, optional): Must include lowercase? Defaults to True.
|
|
73
|
+
upper (bool, optional): Must include uppercase? Defaults to True.
|
|
74
|
+
number (bool, optional): Must include number? Defaults to False.
|
|
75
|
+
special (bool, optional): Must include special characters? Defaults to False.
|
|
76
|
+
min_length (int, optional): What is the minimum length? Defaults to 12.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
str: The formatted password requirement message
|
|
80
|
+
"""
|
|
81
|
+
msg = f"Password needs to be at least {min_length} characters"
|
|
82
|
+
|
|
83
|
+
if lower or upper or number or special:
|
|
84
|
+
msg += " with the following characteristics: "
|
|
85
|
+
specs = []
|
|
86
|
+
if lower:
|
|
87
|
+
specs.append("lowercase letters")
|
|
88
|
+
if upper:
|
|
89
|
+
specs.append("uppercase letters")
|
|
90
|
+
if number:
|
|
91
|
+
specs.append("numbers")
|
|
92
|
+
if special:
|
|
93
|
+
specs.append("special characters")
|
|
94
|
+
|
|
95
|
+
msg += ", ".join(specs)
|
|
96
|
+
|
|
97
|
+
return msg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_password_requirements(
|
|
101
|
+
password: str,
|
|
102
|
+
lower: bool = True,
|
|
103
|
+
upper: bool = True,
|
|
104
|
+
number: bool = False,
|
|
105
|
+
special: bool = False,
|
|
106
|
+
min_length: int = 12,
|
|
107
|
+
) -> bool:
|
|
108
|
+
"""Validate the given password based on the password requirements
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
password (str): The password to check
|
|
112
|
+
lower (bool, optional): Must include lowercase? Defaults to True.
|
|
113
|
+
upper (bool, optional): Must include uppercase? Defaults to True.
|
|
114
|
+
number (bool, optional): Must include number? Defaults to False.
|
|
115
|
+
special (bool, optional): Must include special characters? Defaults to False.
|
|
116
|
+
min_length (int, optional): What is the minimum length? Defaults to 12.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
bool: Does the password meet the requirements?
|
|
120
|
+
"""
|
|
121
|
+
check_upper = re.compile(UPPERCASE)
|
|
122
|
+
check_lower = re.compile(LOWERCASE)
|
|
123
|
+
check_number = re.compile(NUMBER)
|
|
124
|
+
check_special = re.compile(SPECIAL)
|
|
125
|
+
|
|
126
|
+
if get_password_hash(password) is None:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
if len(password) < min_length:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
if upper and len(check_upper.findall(password)) == 0:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
if lower and len(check_lower.findall(password)) == 0:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
if number and len(check_number.findall(password)) == 0:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
if special and len(check_special.findall(password)) == 0:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_random_password(alphabet: Optional[List] = None, length: int = 24) -> str:
|
|
148
|
+
"""Get a random password
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
alphabet (Optional[List], optional): The alphabet to base the password on. Defaults to None.
|
|
152
|
+
length (int, optional): The length of the password. Defaults to 24.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
str: The generated password
|
|
156
|
+
"""
|
|
157
|
+
if alphabet is None:
|
|
158
|
+
alphabet = PASS_BASIC
|
|
159
|
+
r_bytes = bytearray(os.urandom(length))
|
|
160
|
+
a_list = []
|
|
161
|
+
|
|
162
|
+
for byte in r_bytes:
|
|
163
|
+
while byte >= (256 - (256 % len(alphabet))):
|
|
164
|
+
byte = ord(os.urandom(1))
|
|
165
|
+
a_list.append(alphabet[byte % len(alphabet)])
|
|
166
|
+
|
|
167
|
+
return "".join(a_list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_disco_url(host_url: Optional[str]):
|
|
171
|
+
"""Get the discovery URL based on the current host"""
|
|
172
|
+
if type(host_url) is str and "localhost" not in host_url:
|
|
173
|
+
if not host_url.startswith("http"):
|
|
174
|
+
host_url = f"https://{host_url}"
|
|
175
|
+
|
|
176
|
+
original_hostname = urlparse(host_url).hostname
|
|
177
|
+
|
|
178
|
+
if original_hostname:
|
|
179
|
+
hostname = re.sub(r"^(.*?)howler(-stg)?(.+)$", r"\1discover\3", original_hostname)
|
|
180
|
+
|
|
181
|
+
return f"https://{hostname}/eureka/apps"
|
|
182
|
+
else:
|
|
183
|
+
return config.ui.discover_url
|
|
184
|
+
else:
|
|
185
|
+
return config.ui.discover_url
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from flask import Response
|
|
6
|
+
|
|
7
|
+
from howler import actions
|
|
8
|
+
from howler.api import bad_request
|
|
9
|
+
from howler.common.exceptions import HowlerValueError
|
|
10
|
+
from howler.common.loader import datastore
|
|
11
|
+
from howler.common.logging import get_logger
|
|
12
|
+
from howler.common.logging.audit import audit
|
|
13
|
+
from howler.odm.models.action import VALID_TRIGGERS, Action
|
|
14
|
+
from howler.odm.models.user import User
|
|
15
|
+
from howler.utils.str_utils import sanitize_lucene_query
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__file__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_action(new_action: Any) -> Optional[Response]: # noqa: C901
|
|
21
|
+
"""Validate a new action"""
|
|
22
|
+
if not isinstance(new_action, dict):
|
|
23
|
+
return bad_request(err="Incorrect data structure!")
|
|
24
|
+
|
|
25
|
+
if "name" not in new_action:
|
|
26
|
+
return bad_request(err="You must specify a name.")
|
|
27
|
+
elif not new_action["name"]:
|
|
28
|
+
return bad_request(err="Name cannot be empty.")
|
|
29
|
+
|
|
30
|
+
if "query" not in new_action:
|
|
31
|
+
return bad_request(err="You must specify a query.")
|
|
32
|
+
elif not new_action["query"]:
|
|
33
|
+
return bad_request(err="Query cannot be empty.")
|
|
34
|
+
|
|
35
|
+
operations = new_action.get("operations", None)
|
|
36
|
+
if operations is None:
|
|
37
|
+
return bad_request(err="You must specify a list of operations.")
|
|
38
|
+
|
|
39
|
+
if not isinstance(operations, list):
|
|
40
|
+
return bad_request(err="'operations' must be a list of operations.")
|
|
41
|
+
|
|
42
|
+
if len(operations) < 1:
|
|
43
|
+
return bad_request(err="You must specify at least one operation.")
|
|
44
|
+
|
|
45
|
+
operation_ids = [o["operation_id"] for o in operations]
|
|
46
|
+
if len(operation_ids) != len(set(operation_ids)):
|
|
47
|
+
return bad_request(err="You must have a maximum of one operation of each type in the action.")
|
|
48
|
+
|
|
49
|
+
if set(new_action.get("triggers", [])) - set(VALID_TRIGGERS):
|
|
50
|
+
return bad_request(err="Invalid trigger provided.")
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bulk_execute_on_query(query: str, trigger: str = "create", user: Optional[User] = None):
|
|
56
|
+
"""Execute the operations specified in registered actions on the given query"""
|
|
57
|
+
storage = datastore()
|
|
58
|
+
|
|
59
|
+
if trigger not in VALID_TRIGGERS:
|
|
60
|
+
raise HowlerValueError(f"{trigger} is not a valid trigger. It must be one of {','.join(VALID_TRIGGERS)}")
|
|
61
|
+
|
|
62
|
+
on_trigger_actions: list[Action] = storage.action.search(f"triggers:{sanitize_lucene_query(trigger)}", rows=10000)[
|
|
63
|
+
"items"
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
for action in on_trigger_actions:
|
|
67
|
+
intersected_query = f"({query}) AND ({action.query})"
|
|
68
|
+
|
|
69
|
+
if datastore().hit.search(intersected_query, rows=0)["total"] < 1:
|
|
70
|
+
if "pytest" in sys.modules:
|
|
71
|
+
logger.debug("Action %s does not apply to query %s", action.action_id, query)
|
|
72
|
+
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
logger.info("Running action %s on bulk query %s", action.action_id, query)
|
|
76
|
+
for operation in action.operations:
|
|
77
|
+
if operation.operation_id == "example_plugin":
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
parsed_data = json.loads(operation.data_json) if operation.data_json else operation.data
|
|
81
|
+
|
|
82
|
+
audit(
|
|
83
|
+
[],
|
|
84
|
+
{
|
|
85
|
+
"query": intersected_query,
|
|
86
|
+
"operation_id": operation.operation_id,
|
|
87
|
+
**parsed_data,
|
|
88
|
+
},
|
|
89
|
+
user["uname"] if user is not None else "unknown",
|
|
90
|
+
user,
|
|
91
|
+
bulk_execute_on_query,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not user:
|
|
95
|
+
raise NotImplementedError("Running actions without a user object is not currently supported")
|
|
96
|
+
|
|
97
|
+
report = actions.execute(
|
|
98
|
+
operation_id=operation.operation_id,
|
|
99
|
+
query=intersected_query,
|
|
100
|
+
user=user,
|
|
101
|
+
**parsed_data,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for entry in report:
|
|
105
|
+
logger.info(
|
|
106
|
+
"%s (%s): %s",
|
|
107
|
+
operation.operation_id,
|
|
108
|
+
entry["outcome"],
|
|
109
|
+
entry["message"],
|
|
110
|
+
)
|
|
111
|
+
logger.debug("\t%s", entry["query"])
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from typing import Any, Union
|
|
2
|
+
|
|
3
|
+
from howler.common.loader import datastore
|
|
4
|
+
from howler.common.logging import get_logger
|
|
5
|
+
from howler.datastore.exceptions import SearchException
|
|
6
|
+
from howler.datastore.operations import OdmUpdateOperation
|
|
7
|
+
from howler.odm.models.analytic import Analytic
|
|
8
|
+
from howler.odm.models.hit import Hit
|
|
9
|
+
from howler.odm.models.howler_data import Assessment
|
|
10
|
+
from howler.odm.models.user import User
|
|
11
|
+
from howler.utils.str_utils import sanitize_lucene_query
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__file__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def does_analytic_exist(analytic_id: str) -> bool:
|
|
17
|
+
"""Returns true if the analytic_id is already in use."""
|
|
18
|
+
return datastore().analytic.exists(analytic_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_analytic(
|
|
22
|
+
id: str,
|
|
23
|
+
as_obj: bool = False,
|
|
24
|
+
version: bool = False,
|
|
25
|
+
):
|
|
26
|
+
"""Return analytic object as either an ODM or Dict"""
|
|
27
|
+
return datastore().analytic.get_if_exists(key=id, as_obj=as_obj, version=version)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def update_analytic(
|
|
31
|
+
analytic_id: str,
|
|
32
|
+
operations: list[OdmUpdateOperation],
|
|
33
|
+
):
|
|
34
|
+
"""Update one or more properties of an analytic in the database."""
|
|
35
|
+
storage = datastore()
|
|
36
|
+
|
|
37
|
+
result = storage.analytic.update(analytic_id, operations)
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_matching_analytics(hits: Union[list[Hit], list[dict[str, Any]]]) -> list[Analytic]:
|
|
43
|
+
"""Get a list of matching analytics for the given list of hits.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
hits (Union[list[Hit], list[dict[str, Any]]]): A list of Hit objects or dictionaries representing hits.
|
|
47
|
+
Returns:
|
|
48
|
+
list[Analytic]: A list of Analytic objects that match the analytics referenced in the hits.
|
|
49
|
+
"""
|
|
50
|
+
if len(hits) < 1:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
storage = datastore()
|
|
54
|
+
|
|
55
|
+
analytic_names: set[str] = set()
|
|
56
|
+
for hit in hits:
|
|
57
|
+
analytic_names.add(f'"{sanitize_lucene_query(hit["howler"]["analytic"])}"')
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
existing_analytics: list[Analytic] = storage.analytic.search(
|
|
61
|
+
f'name:({" OR ".join(analytic_names)})', as_obj=True
|
|
62
|
+
)["items"]
|
|
63
|
+
|
|
64
|
+
return existing_analytics
|
|
65
|
+
except SearchException:
|
|
66
|
+
logger.exception("Exception on analytic matching")
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_from_hit(hit: Hit, user: User):
|
|
71
|
+
"""Save updates to an analytic based on a new hit that has been created
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
hit (Hit): The newly created hit to use to update the analytic entry
|
|
75
|
+
"""
|
|
76
|
+
storage = datastore()
|
|
77
|
+
|
|
78
|
+
save = False
|
|
79
|
+
existing_analytics: list[Analytic] = storage.analytic.search(
|
|
80
|
+
f'name:"{sanitize_lucene_query(hit.howler.analytic)}"'
|
|
81
|
+
)["items"]
|
|
82
|
+
if len(existing_analytics) > 0:
|
|
83
|
+
analytic: Analytic = existing_analytics[0]
|
|
84
|
+
|
|
85
|
+
if not analytic.owner:
|
|
86
|
+
save = True
|
|
87
|
+
analytic.owner = user["uname"]
|
|
88
|
+
|
|
89
|
+
if user["uname"] not in analytic.contributors:
|
|
90
|
+
analytic.contributors.append(user["uname"])
|
|
91
|
+
|
|
92
|
+
if hit.howler.detection:
|
|
93
|
+
new_detections = [d for d in analytic.detections if d.lower() != (hit.howler.detection or "").lower()]
|
|
94
|
+
new_detections.append(hit.howler.detection)
|
|
95
|
+
|
|
96
|
+
new_detections = sorted(new_detections)
|
|
97
|
+
|
|
98
|
+
if new_detections != analytic.as_primitives()["detections"]:
|
|
99
|
+
save = True
|
|
100
|
+
analytic.detections = new_detections
|
|
101
|
+
|
|
102
|
+
if len(existing_analytics) > 1:
|
|
103
|
+
logger.warning("Duplicate analytics detected! Removing duplicates...")
|
|
104
|
+
for duplicate in existing_analytics[1:]:
|
|
105
|
+
storage.analytic.delete(duplicate.analytic_id)
|
|
106
|
+
|
|
107
|
+
storage.analytic.commit()
|
|
108
|
+
else:
|
|
109
|
+
save = True
|
|
110
|
+
analytic = Analytic(
|
|
111
|
+
{
|
|
112
|
+
"name": hit.howler.analytic,
|
|
113
|
+
"owner": user["uname"],
|
|
114
|
+
"contributors": [user["uname"]],
|
|
115
|
+
"detections": [hit.howler.detection] if hit.howler.detection else [],
|
|
116
|
+
"description": "Placeholder Description - Défaut Description",
|
|
117
|
+
"triage_settings": {
|
|
118
|
+
"valid_assessments": Assessment.list(),
|
|
119
|
+
"skip_rationale": False,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if save:
|
|
125
|
+
storage.analytic.save(analytic.analytic_id, analytic)
|
|
126
|
+
|
|
127
|
+
# This is necessary as we often save over the analytic multiple times in quick succession when saving from hits
|
|
128
|
+
storage.analytic.commit()
|