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,893 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import typing
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from typing import Any, Literal, Optional, Union, cast
|
|
7
|
+
|
|
8
|
+
from prometheus_client import Counter
|
|
9
|
+
|
|
10
|
+
import howler.services.event_service as event_service
|
|
11
|
+
from howler.actions.promote import Escalation
|
|
12
|
+
from howler.common.exceptions import HowlerTypeError, HowlerValueError, NotFoundException, ResourceExists
|
|
13
|
+
from howler.common.loader import APP_NAME, datastore
|
|
14
|
+
from howler.common.logging import get_logger
|
|
15
|
+
from howler.datastore.collection import ESCollection
|
|
16
|
+
from howler.datastore.operations import OdmHelper, OdmUpdateOperation
|
|
17
|
+
from howler.datastore.types import HitSearchResult
|
|
18
|
+
from howler.helper.hit import (
|
|
19
|
+
AssessmentEscalationMap,
|
|
20
|
+
assess_hit,
|
|
21
|
+
assign_hit,
|
|
22
|
+
check_ownership,
|
|
23
|
+
demote_hit,
|
|
24
|
+
promote_hit,
|
|
25
|
+
unassign_hit,
|
|
26
|
+
vote_hit,
|
|
27
|
+
)
|
|
28
|
+
from howler.helper.workflow import Transition, Workflow
|
|
29
|
+
from howler.odm.models.ecs.event import Event
|
|
30
|
+
from howler.odm.models.hit import Hit
|
|
31
|
+
from howler.odm.models.howler_data import HitOperationType, HitStatus, HitStatusTransition, Log
|
|
32
|
+
from howler.odm.models.user import User
|
|
33
|
+
from howler.services import action_service, analytic_service, dossier_service, overview_service, template_service
|
|
34
|
+
from howler.utils.dict_utils import extra_keys, flatten
|
|
35
|
+
from howler.utils.uid import get_random_id
|
|
36
|
+
|
|
37
|
+
logger = get_logger(__file__)
|
|
38
|
+
|
|
39
|
+
odm_helper = OdmHelper(Hit)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_hit_workflow() -> Workflow:
|
|
43
|
+
"""Get the workflow that is used for transitioning between howler statuses
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Workflow: The workflow used to manage hit status transitions
|
|
47
|
+
"""
|
|
48
|
+
return Workflow(
|
|
49
|
+
"howler.status",
|
|
50
|
+
[
|
|
51
|
+
Transition(
|
|
52
|
+
{
|
|
53
|
+
# current user starts investigation
|
|
54
|
+
"source": HitStatus.OPEN,
|
|
55
|
+
"transition": HitStatusTransition.ASSIGN_TO_ME,
|
|
56
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
57
|
+
"actions": [assign_hit],
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
Transition(
|
|
61
|
+
{
|
|
62
|
+
# assign to other user and starts investigation
|
|
63
|
+
"source": HitStatus.OPEN,
|
|
64
|
+
"transition": HitStatusTransition.ASSIGN_TO_OTHER,
|
|
65
|
+
"dest": HitStatus.OPEN,
|
|
66
|
+
"actions": [assign_hit],
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
Transition(
|
|
70
|
+
{
|
|
71
|
+
# assign to other user and starts investigation
|
|
72
|
+
"source": HitStatus.OPEN,
|
|
73
|
+
"transition": HitStatusTransition.START,
|
|
74
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
75
|
+
"actions": [check_ownership],
|
|
76
|
+
}
|
|
77
|
+
),
|
|
78
|
+
Transition(
|
|
79
|
+
{
|
|
80
|
+
# provides vote
|
|
81
|
+
"source": HitStatus.OPEN,
|
|
82
|
+
"transition": HitStatusTransition.VOTE,
|
|
83
|
+
"dest": HitStatus.OPEN,
|
|
84
|
+
"actions": [vote_hit],
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
Transition(
|
|
88
|
+
{
|
|
89
|
+
# assign to another user
|
|
90
|
+
"source": HitStatus.IN_PROGRESS,
|
|
91
|
+
"transition": HitStatusTransition.ASSIGN_TO_OTHER,
|
|
92
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
93
|
+
"actions": [assign_hit],
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
Transition(
|
|
97
|
+
{
|
|
98
|
+
# removes assignment
|
|
99
|
+
"source": HitStatus.IN_PROGRESS,
|
|
100
|
+
"transition": HitStatusTransition.RELEASE,
|
|
101
|
+
"dest": HitStatus.OPEN,
|
|
102
|
+
"actions": [unassign_hit],
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
Transition(
|
|
106
|
+
{
|
|
107
|
+
# user completes investigation
|
|
108
|
+
"source": [HitStatus.OPEN, HitStatus.IN_PROGRESS],
|
|
109
|
+
"transition": HitStatusTransition.ASSESS,
|
|
110
|
+
"dest": HitStatus.RESOLVED,
|
|
111
|
+
"actions": [assess_hit, assign_hit],
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
Transition(
|
|
115
|
+
{
|
|
116
|
+
# vote on in_progress hit
|
|
117
|
+
"source": HitStatus.IN_PROGRESS,
|
|
118
|
+
"transition": HitStatusTransition.VOTE,
|
|
119
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
120
|
+
"actions": [vote_hit],
|
|
121
|
+
}
|
|
122
|
+
),
|
|
123
|
+
Transition(
|
|
124
|
+
{
|
|
125
|
+
# removes assignment
|
|
126
|
+
"source": HitStatus.OPEN,
|
|
127
|
+
"transition": HitStatusTransition.RELEASE,
|
|
128
|
+
"dest": HitStatus.OPEN,
|
|
129
|
+
"actions": [unassign_hit],
|
|
130
|
+
}
|
|
131
|
+
),
|
|
132
|
+
Transition(
|
|
133
|
+
{
|
|
134
|
+
# user pauses investigation
|
|
135
|
+
"source": HitStatus.IN_PROGRESS,
|
|
136
|
+
"transition": HitStatusTransition.PAUSE,
|
|
137
|
+
"dest": HitStatus.ON_HOLD,
|
|
138
|
+
"actions": [check_ownership],
|
|
139
|
+
}
|
|
140
|
+
),
|
|
141
|
+
Transition(
|
|
142
|
+
{
|
|
143
|
+
# user restarts investigation after pausing it
|
|
144
|
+
"source": HitStatus.ON_HOLD,
|
|
145
|
+
"transition": HitStatusTransition.RESUME,
|
|
146
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
147
|
+
"actions": [check_ownership],
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
Transition(
|
|
151
|
+
{
|
|
152
|
+
# current user starts investigation
|
|
153
|
+
"transition": HitStatusTransition.ASSIGN_TO_ME,
|
|
154
|
+
"source": HitStatus.IN_PROGRESS,
|
|
155
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
156
|
+
"actions": [assign_hit],
|
|
157
|
+
}
|
|
158
|
+
),
|
|
159
|
+
Transition(
|
|
160
|
+
{
|
|
161
|
+
# user restarts investigation after pausing it
|
|
162
|
+
"source": HitStatus.ON_HOLD,
|
|
163
|
+
"transition": HitStatusTransition.ASSIGN_TO_OTHER,
|
|
164
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
165
|
+
"actions": [assign_hit],
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
Transition(
|
|
169
|
+
{
|
|
170
|
+
# user restarts investigation after pausing it
|
|
171
|
+
"transition": HitStatusTransition.VOTE,
|
|
172
|
+
"source": HitStatus.ON_HOLD,
|
|
173
|
+
"dest": HitStatus.ON_HOLD,
|
|
174
|
+
"actions": [vote_hit],
|
|
175
|
+
}
|
|
176
|
+
),
|
|
177
|
+
Transition(
|
|
178
|
+
{
|
|
179
|
+
# Reopen a task after resolving it
|
|
180
|
+
"source": HitStatus.RESOLVED,
|
|
181
|
+
"transition": HitStatusTransition.RE_EVALUATE,
|
|
182
|
+
"dest": HitStatus.IN_PROGRESS,
|
|
183
|
+
"actions": [assess_hit, assign_hit],
|
|
184
|
+
}
|
|
185
|
+
),
|
|
186
|
+
Transition(
|
|
187
|
+
{
|
|
188
|
+
# Reopen a task after resolving it
|
|
189
|
+
"source": HitStatus.RESOLVED,
|
|
190
|
+
"transition": HitStatusTransition.VOTE,
|
|
191
|
+
"dest": HitStatus.RESOLVED,
|
|
192
|
+
"actions": [vote_hit],
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
Transition(
|
|
196
|
+
{
|
|
197
|
+
"source": None,
|
|
198
|
+
"transition": HitStatusTransition.PROMOTE,
|
|
199
|
+
"dest": None,
|
|
200
|
+
"actions": [promote_hit],
|
|
201
|
+
}
|
|
202
|
+
),
|
|
203
|
+
Transition(
|
|
204
|
+
{
|
|
205
|
+
"source": None,
|
|
206
|
+
"transition": HitStatusTransition.DEMOTE,
|
|
207
|
+
"actions": [demote_hit],
|
|
208
|
+
"dest": None,
|
|
209
|
+
}
|
|
210
|
+
),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _modifies_prop(prop: str, operations: list[OdmUpdateOperation]) -> bool:
|
|
216
|
+
"""Check if the list of provided operations modifies the specified property
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
prop (str): The property to check for changes
|
|
220
|
+
operations (list[OdmUpdateOperation]): The operations that will be performed
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
bool: Is the property modified by these operations?
|
|
224
|
+
"""
|
|
225
|
+
return any(op for op in operations if op.key == prop)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def does_hit_exist(hit_id: str) -> bool:
|
|
229
|
+
"""Checks if the provided ID matches any entries in the database
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
hit_id (str): The ID to check for in the database
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
bool: Does the ID match a document in the database?
|
|
236
|
+
"""
|
|
237
|
+
return datastore().hit.exists(hit_id)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_hit_ids(hit_ids: list[str]) -> bool:
|
|
241
|
+
"""Checks if all hit_ids are available
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
hit_ids (list[str]): A list of hit ids to validate
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
bool: Whether all of the hit ids are free to use
|
|
248
|
+
"""
|
|
249
|
+
return not any(does_hit_exist(hit_id) for hit_id in hit_ids)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def convert_hit(data: dict[str, Any], unique: bool, ignore_extra_values: bool = False) -> tuple[Hit, list[str]]: # noqa: C901
|
|
253
|
+
"""Validate and convert a dictionary to a Hit ODM object.
|
|
254
|
+
|
|
255
|
+
This function performs comprehensive validation on input data to ensure it can be
|
|
256
|
+
safely converted to a Hit object. It handles hash generation, ID assignment,
|
|
257
|
+
data normalization, and validation warnings. The function also checks for
|
|
258
|
+
deprecated fields and enforces naming conventions for analytics and detections.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
data: Dictionary containing hit data to validate and convert
|
|
262
|
+
unique: Whether to enforce uniqueness by checking if the hit ID already exists
|
|
263
|
+
ignore_extra_values: Whether to ignore invalid extra fields (True) or raise an exception (False)
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Tuple containing:
|
|
267
|
+
- Hit: The validated and converted ODM object
|
|
268
|
+
- list[str]: List of validation warnings (unused fields, deprecated fields, naming issues)
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
HowlerValueError: If bundle is specified during creation, invalid parameters are provided,
|
|
272
|
+
or naming conventions are violated
|
|
273
|
+
HowlerTypeError: If the data cannot be converted to a Hit ODM object
|
|
274
|
+
ResourceExists: If unique=True and a hit with the generated ID already exists
|
|
275
|
+
|
|
276
|
+
Note:
|
|
277
|
+
- Automatically generates a hash based on analytic, detection, and raw data
|
|
278
|
+
- Assigns a random ID if not provided
|
|
279
|
+
- Normalizes data fields to ensure consistent storage format
|
|
280
|
+
- Validates analytic and detection names against best practices (letters and spaces only)
|
|
281
|
+
"""
|
|
282
|
+
data = flatten(data, odm=Hit)
|
|
283
|
+
|
|
284
|
+
if "howler.hash" not in data:
|
|
285
|
+
hash_contents = {
|
|
286
|
+
"analytic": data.get("howler.analytic", "no_analytic"),
|
|
287
|
+
"detection": data.get("howler.detection", "no_detection"),
|
|
288
|
+
"raw_data": data.get("howler.data", {}),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
data["howler.hash"] = sha256(
|
|
292
|
+
json.dumps(hash_contents, sort_keys=True, ensure_ascii=True).encode("utf-8")
|
|
293
|
+
).hexdigest()
|
|
294
|
+
|
|
295
|
+
data["howler.id"] = get_random_id()
|
|
296
|
+
|
|
297
|
+
if "howler.bundles" in data and len(data["howler.bundles"]) > 0:
|
|
298
|
+
raise HowlerValueError("You cannot specify a bundle when creating a hit.")
|
|
299
|
+
|
|
300
|
+
if "howler.data" in data:
|
|
301
|
+
parsed_data = []
|
|
302
|
+
for entry in data["howler.data"]:
|
|
303
|
+
if isinstance(entry, str):
|
|
304
|
+
parsed_data.append(entry)
|
|
305
|
+
else:
|
|
306
|
+
parsed_data.append(json.dumps(entry))
|
|
307
|
+
|
|
308
|
+
data["howler.data"] = parsed_data
|
|
309
|
+
|
|
310
|
+
if "bundle_size" not in data and "howler.hits" in data:
|
|
311
|
+
data["howler.bundle_size"] = len(data["howler.hits"])
|
|
312
|
+
|
|
313
|
+
# TODO: This is a really strange double-validation check we should look to refactor
|
|
314
|
+
try:
|
|
315
|
+
odm = Hit(data, ignore_extra_values=ignore_extra_values)
|
|
316
|
+
except TypeError as e:
|
|
317
|
+
raise HowlerTypeError(str(e), cause=e) from e
|
|
318
|
+
|
|
319
|
+
# Check for deprecated field and unused fields
|
|
320
|
+
odm_flatten = odm.flat_fields(show_compound=True)
|
|
321
|
+
unused_keys = extra_keys(Hit, data)
|
|
322
|
+
|
|
323
|
+
if unused_keys and not ignore_extra_values:
|
|
324
|
+
raise HowlerValueError(f"Hit was created with invalid parameters: {', '.join(unused_keys)}")
|
|
325
|
+
deprecated_keys = set(key for key in odm_flatten.keys() & data.keys() if odm_flatten[key].deprecated)
|
|
326
|
+
|
|
327
|
+
warnings = [f"{key} is not currently used by howler." for key in unused_keys]
|
|
328
|
+
warnings.extend(
|
|
329
|
+
[f"{key} is deprecated." for key in deprecated_keys],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if re.search(r"^([A-Za-z ])+$", odm.howler.analytic) is None:
|
|
333
|
+
warnings.append(
|
|
334
|
+
f"The value {odm.howler.analytic} does not match best practices for Howler analytic names. "
|
|
335
|
+
"See howler's documentation for more information."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if odm.howler.detection and re.search(r"^([A-Za-z ])+$", odm.howler.detection) is None:
|
|
339
|
+
warnings.append(
|
|
340
|
+
f"The value {odm.howler.detection} does not match best practices for Howler detection names. "
|
|
341
|
+
"See howler's documentation for more information."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if odm.event:
|
|
345
|
+
odm.event.id = odm.howler.id
|
|
346
|
+
if not odm.event.created:
|
|
347
|
+
odm.event.created = "NOW"
|
|
348
|
+
else:
|
|
349
|
+
odm.event = Event({"created": "NOW", "id": odm.howler.id})
|
|
350
|
+
|
|
351
|
+
if unique and does_hit_exist(odm.howler.id):
|
|
352
|
+
raise ResourceExists("Resource with id %s already exists" % odm.howler.id)
|
|
353
|
+
|
|
354
|
+
return odm, warnings
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def exists(id: str):
|
|
358
|
+
"""Check if a hit exists in the datastore.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
id: The unique identifier of the hit to check
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
bool: True if the hit exists, False otherwise
|
|
365
|
+
"""
|
|
366
|
+
return datastore().hit.exists(id)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_hit(
|
|
370
|
+
id: str,
|
|
371
|
+
as_odm: bool = False,
|
|
372
|
+
version: bool = False,
|
|
373
|
+
):
|
|
374
|
+
"""Retrieve a hit from the datastore.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
id: The unique identifier of the hit to retrieve
|
|
378
|
+
as_odm: Whether to return the hit as an ODM object (True) or dictionary (False)
|
|
379
|
+
version: Whether to include version information in the response
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Hit object (if as_odm=True) or dictionary representation of the hit.
|
|
383
|
+
Returns None if the hit doesn't exist.
|
|
384
|
+
"""
|
|
385
|
+
return datastore().hit.get_if_exists(key=id, as_obj=as_odm, version=version)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
CREATED_HITS = Counter(
|
|
389
|
+
f"{APP_NAME.replace('-', '_')}_created_hits_total",
|
|
390
|
+
"The number of created hits",
|
|
391
|
+
["analytic"],
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def create_hit(
|
|
396
|
+
id: str,
|
|
397
|
+
hit: Hit,
|
|
398
|
+
user: Optional[str] = None,
|
|
399
|
+
overwrite: bool = False,
|
|
400
|
+
) -> bool:
|
|
401
|
+
"""Create a new hit in the database.
|
|
402
|
+
|
|
403
|
+
This function saves a hit to the datastore, optionally adding a creation log entry
|
|
404
|
+
and updating metrics. It will prevent overwriting existing hits unless explicitly allowed.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
id: The unique identifier for the hit
|
|
408
|
+
hit: The Hit ODM object to save
|
|
409
|
+
user: Optional username to record in the creation log
|
|
410
|
+
overwrite: Whether to allow overwriting an existing hit with the same ID
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
bool: True if the hit was successfully created
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
ResourceExists: If a hit with the same ID already exists and overwrite=False
|
|
417
|
+
"""
|
|
418
|
+
if not overwrite and does_hit_exist(id):
|
|
419
|
+
raise ResourceExists("Hit %s already exists in datastore" % id)
|
|
420
|
+
|
|
421
|
+
if user:
|
|
422
|
+
hit.howler.log = [Log({"timestamp": "NOW", "explanation": "Created hit", "user": user})]
|
|
423
|
+
|
|
424
|
+
CREATED_HITS.labels(hit.howler.analytic).inc()
|
|
425
|
+
return datastore().hit.save(id, hit)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def update_hit(
|
|
429
|
+
hit_id: str,
|
|
430
|
+
operations: list[OdmUpdateOperation],
|
|
431
|
+
user: Optional[str] = None,
|
|
432
|
+
version: Optional[str] = None,
|
|
433
|
+
):
|
|
434
|
+
"""Update one or more properties of a hit in the database.
|
|
435
|
+
|
|
436
|
+
This function applies a list of update operations to modify hit properties.
|
|
437
|
+
Note that hit status cannot be modified through this function - use transition_hit instead.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
hit_id: The unique identifier of the hit to update
|
|
441
|
+
operations: List of ODM update operations to apply
|
|
442
|
+
user: Optional username to record in the update log
|
|
443
|
+
version: Optional version string for optimistic locking
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Tuple of (updated_hit_data, new_version)
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
HowlerValueError: If attempting to modify hit status through this function
|
|
450
|
+
"""
|
|
451
|
+
# Status of a hit should only be updated through the transition function
|
|
452
|
+
if _modifies_prop("howler.status", operations):
|
|
453
|
+
raise HowlerValueError(
|
|
454
|
+
"Status of a Hit cannot be modified like other properties. Please use a transition to do so."
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return _update_hit(hit_id, operations, user, version=version)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@typing.no_type_check
|
|
461
|
+
def save_hit(hit: Hit, version: Optional[str] = None) -> tuple[Hit, str]:
|
|
462
|
+
"""Save a hit to the datastore and emit an event notification.
|
|
463
|
+
|
|
464
|
+
This function persists a hit object to the database and emits an event
|
|
465
|
+
to notify other systems of the change.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
hit: The Hit ODM object to save
|
|
469
|
+
version: Optional version string for optimistic locking
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Tuple of (hit_data_dict, version_string)
|
|
473
|
+
"""
|
|
474
|
+
datastore().hit.save(hit.howler.id, hit, version=version)
|
|
475
|
+
data, _version = datastore().hit.get(hit.howler.id, as_obj=False, version=True)
|
|
476
|
+
event_service.emit("hits", {"hit": data, "version": _version})
|
|
477
|
+
|
|
478
|
+
return data, version
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _update_hit(
|
|
482
|
+
hit_id: str,
|
|
483
|
+
operations: list[OdmUpdateOperation],
|
|
484
|
+
user: Optional[str] = None,
|
|
485
|
+
version: Optional[str] = None,
|
|
486
|
+
) -> tuple[Hit, str]:
|
|
487
|
+
"""Internal function to update a hit with proper logging and event emission.
|
|
488
|
+
|
|
489
|
+
This function applies update operations to a hit, automatically adding worklog entries
|
|
490
|
+
for non-silent operations and emitting events to notify other systems of changes.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
hit_id: The unique identifier of the hit to update
|
|
494
|
+
operations: List of ODM update operations to apply
|
|
495
|
+
user: Optional username to record in operation logs
|
|
496
|
+
version: Optional version string for optimistic locking
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Tuple of (updated_hit_data, new_version)
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
HowlerValueError: If user parameter is provided but not a string
|
|
503
|
+
"""
|
|
504
|
+
final_operations = []
|
|
505
|
+
|
|
506
|
+
if user and not isinstance(user, str):
|
|
507
|
+
raise HowlerValueError("User must be of type string")
|
|
508
|
+
|
|
509
|
+
current_hit = get_hit(hit_id, as_odm=True)
|
|
510
|
+
|
|
511
|
+
for operation in operations:
|
|
512
|
+
if not operation:
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
is_list = current_hit.flat_fields()[operation.key].multivalued
|
|
517
|
+
try:
|
|
518
|
+
previous_value = current_hit[operation.key]
|
|
519
|
+
except (TypeError, KeyError):
|
|
520
|
+
previous_value = None
|
|
521
|
+
except KeyError:
|
|
522
|
+
key = next(key for key in current_hit.flat_fields().keys() if key.startswith(operation.key))
|
|
523
|
+
is_list = current_hit.flat_fields()[key].multivalued
|
|
524
|
+
previous_value = "list"
|
|
525
|
+
|
|
526
|
+
operation_type = ""
|
|
527
|
+
if is_list:
|
|
528
|
+
if operation.operation in (
|
|
529
|
+
ESCollection.UPDATE_APPEND,
|
|
530
|
+
ESCollection.UPDATE_APPEND_IF_MISSING,
|
|
531
|
+
):
|
|
532
|
+
operation_type = HitOperationType.APPENDED
|
|
533
|
+
else:
|
|
534
|
+
operation_type = HitOperationType.REMOVED
|
|
535
|
+
else:
|
|
536
|
+
operation_type = HitOperationType.SET
|
|
537
|
+
|
|
538
|
+
logger.debug("%s - %s - %s -> %s", hit_id, operation.key, previous_value, operation.value)
|
|
539
|
+
final_operations.append(operation)
|
|
540
|
+
|
|
541
|
+
if not operation.silent:
|
|
542
|
+
final_operations.append(
|
|
543
|
+
OdmUpdateOperation(
|
|
544
|
+
ESCollection.UPDATE_APPEND,
|
|
545
|
+
"howler.log",
|
|
546
|
+
{
|
|
547
|
+
"timestamp": "NOW",
|
|
548
|
+
"previous_version": version,
|
|
549
|
+
"key": operation.key,
|
|
550
|
+
"explanation": operation.explanation,
|
|
551
|
+
"new_value": operation.value or "None",
|
|
552
|
+
"previous_value": previous_value or "None",
|
|
553
|
+
"type": operation_type,
|
|
554
|
+
"user": user if user else "Unknown",
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
datastore().hit.update(hit_id, final_operations, version)
|
|
560
|
+
# Need to fetch the new data of the hit for the event_service
|
|
561
|
+
data, _version = datastore().hit.get(hit_id, as_obj=False, version=True)
|
|
562
|
+
event_service.emit("hits", {"hit": data, "version": _version})
|
|
563
|
+
|
|
564
|
+
return data, _version
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def get_transitions(status: HitStatus) -> list[str]:
|
|
568
|
+
"""Get a list of the valid transitions beginning from the specified status
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
status (HitStatus): The status we want to transition from
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
list[str]: A list of valid transitions to execute
|
|
575
|
+
"""
|
|
576
|
+
return get_hit_workflow().get_transitions(status)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def get_all_children(hit: Hit):
|
|
580
|
+
"""Get a list of all child hits for a given hit, including nested children.
|
|
581
|
+
|
|
582
|
+
This function recursively traverses bundle structures to find all child hits.
|
|
583
|
+
If a child hit is itself a bundle, it will recursively get its children too.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
hit: The parent hit to get children for
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
List of all child hits (may include None values for missing hits)
|
|
590
|
+
"""
|
|
591
|
+
# Get immediate child hits from the hit's bundle
|
|
592
|
+
child_hits = [get_hit(hit_id) for hit_id in hit["howler"].get("hits", [])]
|
|
593
|
+
|
|
594
|
+
# Recursively process child hits that are themselves bundles
|
|
595
|
+
for entry in child_hits:
|
|
596
|
+
if not entry:
|
|
597
|
+
continue
|
|
598
|
+
|
|
599
|
+
# If this child is a bundle, get its children too
|
|
600
|
+
if entry["howler"]["is_bundle"]:
|
|
601
|
+
child_hits.extend(get_all_children(entry))
|
|
602
|
+
|
|
603
|
+
return child_hits
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def transition_hit(
|
|
607
|
+
id: str,
|
|
608
|
+
transition: HitStatusTransition,
|
|
609
|
+
user: User,
|
|
610
|
+
version: Optional[str] = None,
|
|
611
|
+
**kwargs,
|
|
612
|
+
):
|
|
613
|
+
"""Transition a hit from one status to another while updating related properties.
|
|
614
|
+
|
|
615
|
+
This function handles status transitions for both individual hits and bundles,
|
|
616
|
+
applying the same transition to all child hits in a bundle. For certain transitions
|
|
617
|
+
(PROMOTE, DEMOTE, ASSESS, RE_EVALUATE), it also executes bulk actions and emits events.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
id: The id of the hit to transition
|
|
621
|
+
transition: The transition to execute (e.g., ASSIGN_TO_ME, ASSESS, PROMOTE)
|
|
622
|
+
user: The user running the transition
|
|
623
|
+
version: Optional version to validate against. The transition will not run if the version doesn't match.
|
|
624
|
+
**kwargs: Additional arguments including potential 'hit' object and 'assessment' value
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
NotFoundException: If the hit does not exist
|
|
628
|
+
"""
|
|
629
|
+
# Get the primary hit (either provided in kwargs or fetch from database)
|
|
630
|
+
primary_hit: Hit = kwargs.pop("hit", None) or get_hit(id, as_odm=False)
|
|
631
|
+
|
|
632
|
+
if not primary_hit:
|
|
633
|
+
raise NotFoundException("Hit does not exist")
|
|
634
|
+
|
|
635
|
+
workflow: Workflow = get_hit_workflow()
|
|
636
|
+
|
|
637
|
+
# Get all child hits that need to be processed along with the primary hit
|
|
638
|
+
child_hits = get_all_children(primary_hit)
|
|
639
|
+
primary_hit_status = primary_hit["howler"]["status"]
|
|
640
|
+
|
|
641
|
+
# Log all hits that will be transitioned
|
|
642
|
+
all_hit_ids = [h["howler"]["id"] for h in ([primary_hit] + [ch for ch in child_hits if ch])]
|
|
643
|
+
logger.debug("Transitioning (%s)", ", ".join(all_hit_ids))
|
|
644
|
+
|
|
645
|
+
# Process each hit (primary + children) with the workflow transition
|
|
646
|
+
for current_hit in [primary_hit] + [ch for ch in child_hits if ch]:
|
|
647
|
+
current_hit_status = current_hit["howler"]["status"]
|
|
648
|
+
current_hit_id = current_hit["howler"]["id"]
|
|
649
|
+
|
|
650
|
+
# Skip hits that don't match the primary hit's status
|
|
651
|
+
# This ensures consistent state transitions across bundles
|
|
652
|
+
if current_hit_status != primary_hit_status:
|
|
653
|
+
logger.debug("Skipping %s (status mismatch)", current_hit_id)
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
# Apply the workflow transition to get required updates
|
|
657
|
+
updates = workflow.transition(current_hit_status, transition, user=user, hit=current_hit, **kwargs)
|
|
658
|
+
|
|
659
|
+
# Apply updates if any were generated by the workflow
|
|
660
|
+
if updates:
|
|
661
|
+
# Only apply version validation to the primary hit
|
|
662
|
+
hit_version = version if (current_hit_id == primary_hit["howler"]["id"] and version) else None
|
|
663
|
+
_update_hit(current_hit_id, updates, user["uname"], version=hit_version)
|
|
664
|
+
|
|
665
|
+
# Execute bulk actions for transitions that require them
|
|
666
|
+
# These transitions need additional processing beyond the workflow
|
|
667
|
+
transitions_requiring_bulk_actions = [
|
|
668
|
+
HitStatusTransition.PROMOTE,
|
|
669
|
+
HitStatusTransition.DEMOTE,
|
|
670
|
+
HitStatusTransition.ASSESS,
|
|
671
|
+
HitStatusTransition.RE_EVALUATE,
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
if transition in transitions_requiring_bulk_actions:
|
|
675
|
+
# Determine the trigger action (promote/demote) based on transition type
|
|
676
|
+
trigger: Union[Literal["promote"], Literal["demote"]]
|
|
677
|
+
|
|
678
|
+
if transition == HitStatusTransition.ASSESS:
|
|
679
|
+
# For assessments, determine promotion/demotion based on escalation level
|
|
680
|
+
new_escalation = AssessmentEscalationMap[kwargs["assessment"]]
|
|
681
|
+
trigger = "promote" if new_escalation == Escalation.EVIDENCE else "demote"
|
|
682
|
+
elif transition == HitStatusTransition.RE_EVALUATE:
|
|
683
|
+
# Re-evaluation always promotes the hit
|
|
684
|
+
trigger = "promote"
|
|
685
|
+
else:
|
|
686
|
+
# For direct PROMOTE/DEMOTE transitions, use the transition name
|
|
687
|
+
trigger = cast(Union[Literal["promote"], Literal["demote"]], transition)
|
|
688
|
+
|
|
689
|
+
# Commit database changes before executing bulk actions
|
|
690
|
+
datastore().hit.commit()
|
|
691
|
+
|
|
692
|
+
# Build query for all processed hits (primary + children)
|
|
693
|
+
all_processed_hits = [primary_hit] + child_hits
|
|
694
|
+
hit_query = f"howler.id:({' OR '.join(h['howler']['id'] for h in all_processed_hits)})"
|
|
695
|
+
|
|
696
|
+
# Execute bulk actions on all hits
|
|
697
|
+
action_service.bulk_execute_on_query(hit_query, trigger=trigger, user=user)
|
|
698
|
+
|
|
699
|
+
# Emit events for all processed hits to notify other systems
|
|
700
|
+
for processed_hit in all_processed_hits:
|
|
701
|
+
data, hit_version = datastore().hit.get(processed_hit["howler"]["id"], as_obj=False, version=True)
|
|
702
|
+
event_service.emit("hits", {"hit": data, "version": hit_version})
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
DELETED_HITS = Counter(f"{APP_NAME.replace('-', '_')}_deleted_hits_total", "The number of deleted hits")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def delete_hits(hit_ids: list[str]) -> bool:
|
|
709
|
+
"""Delete a set of hits from the database
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
hit_ids (list[str]): The IDs of the hits to delete
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
bool: Was the deletion successful?
|
|
716
|
+
"""
|
|
717
|
+
ds = datastore()
|
|
718
|
+
|
|
719
|
+
operations = []
|
|
720
|
+
result = True
|
|
721
|
+
|
|
722
|
+
for hit_id in hit_ids:
|
|
723
|
+
operations.append(odm_helper.list_remove("howler.hits", hit_id, silent=True))
|
|
724
|
+
|
|
725
|
+
result = result and ds.hit.delete(hit_id)
|
|
726
|
+
DELETED_HITS.inc()
|
|
727
|
+
|
|
728
|
+
ds.hit.update_by_query("howler.is_bundle:true", operations)
|
|
729
|
+
|
|
730
|
+
ds.hit.commit()
|
|
731
|
+
|
|
732
|
+
return result
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def search(
|
|
736
|
+
query: str,
|
|
737
|
+
offset: int = 0,
|
|
738
|
+
rows: Optional[int] = None,
|
|
739
|
+
sort: Optional[Any] = None,
|
|
740
|
+
fl: Optional[Any] = None,
|
|
741
|
+
timeout: Optional[Any] = None,
|
|
742
|
+
deep_paging_id: Optional[Any] = None,
|
|
743
|
+
track_total_hits: Optional[Any] = None,
|
|
744
|
+
as_obj: bool = True,
|
|
745
|
+
) -> HitSearchResult:
|
|
746
|
+
"""Search for hits in the datastore using a query.
|
|
747
|
+
|
|
748
|
+
This function provides a flexible search interface for finding hits based on
|
|
749
|
+
various criteria. It supports pagination, sorting, field limiting, and other
|
|
750
|
+
advanced search features.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
query: The search query string (supports Lucene syntax)
|
|
754
|
+
offset: Number of results to skip (for pagination)
|
|
755
|
+
rows: Maximum number of results to return
|
|
756
|
+
sort: Sort criteria for the results
|
|
757
|
+
fl: Field list - which fields to include in results
|
|
758
|
+
timeout: Query timeout duration
|
|
759
|
+
deep_paging_id: Identifier for deep pagination
|
|
760
|
+
track_total_hits: Whether to track the total hit count
|
|
761
|
+
as_obj: Whether to return results as ODM objects (True) or dictionaries (False)
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
HitSearchResult containing the matching hits and metadata
|
|
765
|
+
"""
|
|
766
|
+
return datastore().hit.search(
|
|
767
|
+
query=query,
|
|
768
|
+
offset=offset,
|
|
769
|
+
rows=rows,
|
|
770
|
+
sort=sort,
|
|
771
|
+
fl=fl,
|
|
772
|
+
timeout=timeout,
|
|
773
|
+
deep_paging_id=deep_paging_id,
|
|
774
|
+
track_total_hits=track_total_hits,
|
|
775
|
+
as_obj=as_obj,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
TYPE_PRIORITY = {"personal": 2, "readonly": 1, "global": 0, None: 0}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def __compare_metadata(object_a: dict[str, Any], object_b: dict[str, Any]) -> int:
|
|
783
|
+
# Sort priority:
|
|
784
|
+
# 1. personal > readonly > global
|
|
785
|
+
# 2. detection > !detection
|
|
786
|
+
|
|
787
|
+
if object_a.get("type", None) != object_b.get("type", None):
|
|
788
|
+
return TYPE_PRIORITY[object_b.get("type", None)] - TYPE_PRIORITY[object_a.get("type", None)]
|
|
789
|
+
|
|
790
|
+
if object_a.get("detection", None) and not object_b.get("detection", None):
|
|
791
|
+
return -1
|
|
792
|
+
|
|
793
|
+
if not object_a.get("detection", None) and object_b.get("detection", None):
|
|
794
|
+
return 1
|
|
795
|
+
|
|
796
|
+
return 0
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def __match_metadata(candidates: list[dict[str, Any]], hit: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
800
|
+
matching_candidates: list[dict[str, Any]] = []
|
|
801
|
+
|
|
802
|
+
for candidate in candidates:
|
|
803
|
+
if candidate["analytic"].lower() != hit["howler"]["analytic"].lower():
|
|
804
|
+
continue
|
|
805
|
+
|
|
806
|
+
if not candidate.get("detection", None):
|
|
807
|
+
matching_candidates.append(candidate)
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
if not hit["howler"].get("detection", None):
|
|
811
|
+
continue
|
|
812
|
+
|
|
813
|
+
if hit["howler"]["detection"].lower() != candidate["detection"].lower():
|
|
814
|
+
continue
|
|
815
|
+
|
|
816
|
+
matching_candidates.append(candidate)
|
|
817
|
+
|
|
818
|
+
if len(matching_candidates) < 1:
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
return sorted(matching_candidates, key=functools.cmp_to_key(__compare_metadata))[0]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def augment_metadata(data: list[dict[str, Any]] | dict[str, Any], metadata: list[str], user: dict[str, Any]): # noqa: C901
|
|
825
|
+
"""Augment hit search results with additional metadata.
|
|
826
|
+
|
|
827
|
+
This function enriches hit data by adding related information such as templates,
|
|
828
|
+
overviews, and matching dossiers. The metadata is added as special fields prefixed
|
|
829
|
+
with double underscores (e.g., __template, __overview, __dossiers).
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
data: Hit data - either a single hit dictionary or list of hit dictionaries
|
|
833
|
+
metadata: List of metadata types to include ('template', 'overview', 'dossiers')
|
|
834
|
+
user: User context for determining accessible templates and other user-specific data
|
|
835
|
+
|
|
836
|
+
Note:
|
|
837
|
+
This function modifies the input data in-place, adding metadata fields.
|
|
838
|
+
Templates are filtered based on user permissions (global or owned by user).
|
|
839
|
+
"""
|
|
840
|
+
if isinstance(data, list):
|
|
841
|
+
hits = data
|
|
842
|
+
elif data is not None:
|
|
843
|
+
hits = [data]
|
|
844
|
+
else:
|
|
845
|
+
hits = []
|
|
846
|
+
|
|
847
|
+
if len(hits) < 1:
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
logger.debug("Augmenting %s hits with %s", len(hits), ",".join(metadata))
|
|
851
|
+
|
|
852
|
+
if "template" in metadata:
|
|
853
|
+
template_candidates = template_service.get_matching_templates(hits, user["uname"], as_odm=False)
|
|
854
|
+
|
|
855
|
+
logger.debug("\tRetrieved %s matching templates", len(template_candidates))
|
|
856
|
+
|
|
857
|
+
for hit in hits:
|
|
858
|
+
hit["__template"] = __match_metadata(cast(list[dict[str, Any]], template_candidates), hit)
|
|
859
|
+
|
|
860
|
+
if "overview" in metadata:
|
|
861
|
+
overview_candidates = overview_service.get_matching_overviews(hits, as_odm=False)
|
|
862
|
+
|
|
863
|
+
logger.debug("\tRetrieved %s matching overviews", len(overview_candidates))
|
|
864
|
+
|
|
865
|
+
for hit in hits:
|
|
866
|
+
hit["__overview"] = __match_metadata(cast(list[dict[str, Any]], overview_candidates), hit)
|
|
867
|
+
|
|
868
|
+
if "analytic" in metadata:
|
|
869
|
+
matched_analytics = analytic_service.get_matching_analytics(hits)
|
|
870
|
+
logger.debug("\tRetrieved %s matching analytics", len(matched_analytics))
|
|
871
|
+
|
|
872
|
+
for hit in hits:
|
|
873
|
+
matched_analytic = next(
|
|
874
|
+
(
|
|
875
|
+
analytic
|
|
876
|
+
for analytic in matched_analytics
|
|
877
|
+
if analytic.name.lower() == hit["howler"]["analytic"].lower()
|
|
878
|
+
),
|
|
879
|
+
None,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
hit["__analytic"] = matched_analytic.as_primitives() if matched_analytic else None
|
|
883
|
+
|
|
884
|
+
if "dossiers" in metadata:
|
|
885
|
+
dossiers: list[dict[str, Any]] = datastore().dossier.search(
|
|
886
|
+
"dossier_id:*",
|
|
887
|
+
as_obj=False,
|
|
888
|
+
# TODO: Eventually implement caching here
|
|
889
|
+
rows=1000,
|
|
890
|
+
)["items"]
|
|
891
|
+
|
|
892
|
+
for hit in hits:
|
|
893
|
+
hit["__dossiers"] = dossier_service.get_matching_dossiers(hit, dossiers)
|