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.
Files changed (200) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +167 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/borealis.py +101 -0
  21. howler/api/v1/configs.py +55 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +715 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +414 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +144 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/hexdump.py +48 -0
  41. howler/common/iprange.py +171 -0
  42. howler/common/loader.py +154 -0
  43. howler/common/logging/__init__.py +241 -0
  44. howler/common/logging/audit.py +138 -0
  45. howler/common/logging/format.py +38 -0
  46. howler/common/net.py +79 -0
  47. howler/common/net_static.py +1494 -0
  48. howler/common/random_user.py +316 -0
  49. howler/common/swagger.py +117 -0
  50. howler/config.py +64 -0
  51. howler/cronjobs/__init__.py +29 -0
  52. howler/cronjobs/retention.py +61 -0
  53. howler/cronjobs/rules.py +274 -0
  54. howler/cronjobs/view_cleanup.py +88 -0
  55. howler/datastore/README.md +112 -0
  56. howler/datastore/__init__.py +0 -0
  57. howler/datastore/bulk.py +72 -0
  58. howler/datastore/collection.py +2327 -0
  59. howler/datastore/constants.py +117 -0
  60. howler/datastore/exceptions.py +41 -0
  61. howler/datastore/howler_store.py +105 -0
  62. howler/datastore/migrations/fix_process.py +41 -0
  63. howler/datastore/operations.py +130 -0
  64. howler/datastore/schemas.py +90 -0
  65. howler/datastore/store.py +231 -0
  66. howler/datastore/support/__init__.py +0 -0
  67. howler/datastore/support/build.py +214 -0
  68. howler/datastore/support/schemas.py +90 -0
  69. howler/datastore/types.py +22 -0
  70. howler/error.py +91 -0
  71. howler/external/__init__.py +0 -0
  72. howler/external/generate_mitre.py +96 -0
  73. howler/external/generate_sigma_rules.py +31 -0
  74. howler/external/generate_tlds.py +47 -0
  75. howler/external/reindex_data.py +46 -0
  76. howler/external/wipe_databases.py +58 -0
  77. howler/gunicorn_config.py +25 -0
  78. howler/healthz.py +47 -0
  79. howler/helper/__init__.py +0 -0
  80. howler/helper/azure.py +50 -0
  81. howler/helper/discover.py +59 -0
  82. howler/helper/hit.py +236 -0
  83. howler/helper/oauth.py +247 -0
  84. howler/helper/search.py +92 -0
  85. howler/helper/workflow.py +110 -0
  86. howler/helper/ws.py +378 -0
  87. howler/odm/README.md +102 -0
  88. howler/odm/__init__.py +1 -0
  89. howler/odm/base.py +1504 -0
  90. howler/odm/charter.txt +146 -0
  91. howler/odm/helper.py +416 -0
  92. howler/odm/howler_enum.py +25 -0
  93. howler/odm/models/__init__.py +0 -0
  94. howler/odm/models/action.py +33 -0
  95. howler/odm/models/analytic.py +90 -0
  96. howler/odm/models/assemblyline.py +48 -0
  97. howler/odm/models/aws.py +23 -0
  98. howler/odm/models/azure.py +16 -0
  99. howler/odm/models/cbs.py +44 -0
  100. howler/odm/models/config.py +558 -0
  101. howler/odm/models/dossier.py +33 -0
  102. howler/odm/models/ecs/__init__.py +0 -0
  103. howler/odm/models/ecs/agent.py +17 -0
  104. howler/odm/models/ecs/autonomous_system.py +16 -0
  105. howler/odm/models/ecs/client.py +149 -0
  106. howler/odm/models/ecs/cloud.py +141 -0
  107. howler/odm/models/ecs/code_signature.py +27 -0
  108. howler/odm/models/ecs/container.py +32 -0
  109. howler/odm/models/ecs/dns.py +62 -0
  110. howler/odm/models/ecs/egress.py +10 -0
  111. howler/odm/models/ecs/elf.py +74 -0
  112. howler/odm/models/ecs/email.py +122 -0
  113. howler/odm/models/ecs/error.py +14 -0
  114. howler/odm/models/ecs/event.py +140 -0
  115. howler/odm/models/ecs/faas.py +24 -0
  116. howler/odm/models/ecs/file.py +84 -0
  117. howler/odm/models/ecs/geo.py +30 -0
  118. howler/odm/models/ecs/group.py +18 -0
  119. howler/odm/models/ecs/hash.py +16 -0
  120. howler/odm/models/ecs/host.py +17 -0
  121. howler/odm/models/ecs/http.py +37 -0
  122. howler/odm/models/ecs/ingress.py +12 -0
  123. howler/odm/models/ecs/interface.py +21 -0
  124. howler/odm/models/ecs/network.py +30 -0
  125. howler/odm/models/ecs/observer.py +45 -0
  126. howler/odm/models/ecs/organization.py +12 -0
  127. howler/odm/models/ecs/os.py +21 -0
  128. howler/odm/models/ecs/pe.py +17 -0
  129. howler/odm/models/ecs/process.py +216 -0
  130. howler/odm/models/ecs/registry.py +26 -0
  131. howler/odm/models/ecs/related.py +45 -0
  132. howler/odm/models/ecs/rule.py +51 -0
  133. howler/odm/models/ecs/server.py +24 -0
  134. howler/odm/models/ecs/threat.py +247 -0
  135. howler/odm/models/ecs/tls.py +58 -0
  136. howler/odm/models/ecs/url.py +51 -0
  137. howler/odm/models/ecs/user.py +57 -0
  138. howler/odm/models/ecs/user_agent.py +20 -0
  139. howler/odm/models/ecs/vulnerability.py +41 -0
  140. howler/odm/models/gcp.py +16 -0
  141. howler/odm/models/hit.py +356 -0
  142. howler/odm/models/howler_data.py +328 -0
  143. howler/odm/models/lead.py +33 -0
  144. howler/odm/models/localized_label.py +13 -0
  145. howler/odm/models/overview.py +16 -0
  146. howler/odm/models/pivot.py +40 -0
  147. howler/odm/models/template.py +24 -0
  148. howler/odm/models/user.py +83 -0
  149. howler/odm/models/view.py +34 -0
  150. howler/odm/random_data.py +888 -0
  151. howler/odm/randomizer.py +606 -0
  152. howler/patched.py +5 -0
  153. howler/plugins/__init__.py +25 -0
  154. howler/plugins/config.py +123 -0
  155. howler/remote/__init__.py +0 -0
  156. howler/remote/datatypes/README.md +355 -0
  157. howler/remote/datatypes/__init__.py +98 -0
  158. howler/remote/datatypes/counters.py +63 -0
  159. howler/remote/datatypes/events.py +66 -0
  160. howler/remote/datatypes/hash.py +206 -0
  161. howler/remote/datatypes/lock.py +42 -0
  162. howler/remote/datatypes/queues/__init__.py +0 -0
  163. howler/remote/datatypes/queues/comms.py +59 -0
  164. howler/remote/datatypes/queues/multi.py +32 -0
  165. howler/remote/datatypes/queues/named.py +93 -0
  166. howler/remote/datatypes/queues/priority.py +215 -0
  167. howler/remote/datatypes/set.py +118 -0
  168. howler/remote/datatypes/user_quota_tracker.py +54 -0
  169. howler/security/__init__.py +253 -0
  170. howler/security/socket.py +108 -0
  171. howler/security/utils.py +185 -0
  172. howler/services/__init__.py +0 -0
  173. howler/services/action_service.py +111 -0
  174. howler/services/analytic_service.py +128 -0
  175. howler/services/auth_service.py +323 -0
  176. howler/services/config_service.py +128 -0
  177. howler/services/dossier_service.py +252 -0
  178. howler/services/event_service.py +93 -0
  179. howler/services/hit_service.py +893 -0
  180. howler/services/jwt_service.py +158 -0
  181. howler/services/lucene_service.py +286 -0
  182. howler/services/notebook_service.py +119 -0
  183. howler/services/overview_service.py +44 -0
  184. howler/services/template_service.py +45 -0
  185. howler/services/user_service.py +330 -0
  186. howler/utils/__init__.py +0 -0
  187. howler/utils/annotations.py +28 -0
  188. howler/utils/chunk.py +38 -0
  189. howler/utils/dict_utils.py +200 -0
  190. howler/utils/isotime.py +17 -0
  191. howler/utils/list_utils.py +11 -0
  192. howler/utils/lucene.py +77 -0
  193. howler/utils/path.py +27 -0
  194. howler/utils/socket_utils.py +61 -0
  195. howler/utils/str_utils.py +256 -0
  196. howler/utils/uid.py +47 -0
  197. howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
  198. howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
  199. howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
  200. howler_api-2.13.0.dev329.dist-info/entry_points.txt +8 -0
@@ -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()