howler-api 2.10.0.dev255__py3-none-any.whl → 2.13.0.dev344__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/api/__init__.py +1 -1
- howler/api/v1/auth.py +1 -1
- howler/api/v1/{borealis.py → clue.py} +24 -26
- howler/api/v1/dossier.py +4 -28
- howler/api/v1/hit.py +11 -7
- howler/api/v1/search.py +160 -17
- howler/api/v1/user.py +2 -2
- howler/api/v1/utils/etag.py +43 -5
- howler/api/v1/view.py +26 -34
- howler/app.py +4 -4
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +0 -2
- howler/datastore/collection.py +109 -132
- howler/datastore/howler_store.py +0 -45
- howler/datastore/store.py +25 -6
- howler/odm/base.py +1 -1
- howler/odm/helper.py +9 -6
- howler/odm/models/config.py +168 -8
- howler/odm/models/howler_data.py +2 -1
- howler/odm/models/lead.py +1 -10
- howler/odm/models/pivot.py +2 -11
- howler/odm/random_data.py +1 -1
- howler/security/__init__.py +2 -2
- howler/services/analytic_service.py +31 -0
- howler/services/config_service.py +2 -2
- howler/services/dossier_service.py +140 -7
- howler/services/hit_service.py +317 -72
- howler/services/lucene_service.py +14 -7
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/utils/lucene.py +22 -2
- {howler_api-2.10.0.dev255.dist-info → howler_api-2.13.0.dev344.dist-info}/METADATA +5 -5
- {howler_api-2.10.0.dev255.dist-info → howler_api-2.13.0.dev344.dist-info}/RECORD +35 -32
- {howler_api-2.10.0.dev255.dist-info → howler_api-2.13.0.dev344.dist-info}/WHEEL +1 -1
- {howler_api-2.10.0.dev255.dist-info → howler_api-2.13.0.dev344.dist-info}/entry_points.txt +0 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"""Dossier service module for managing security investigation dossiers.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for creating, updating, retrieving, and managing
|
|
4
|
+
dossiers - collections of security alerts and investigation data organized by analysts.
|
|
5
|
+
Dossiers can be personal (private to the creator) or global (shared with the team).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional, cast
|
|
2
9
|
|
|
3
10
|
from mergedeep.mergedeep import merge
|
|
4
11
|
|
|
@@ -8,9 +15,11 @@ from howler.common.logging import get_logger
|
|
|
8
15
|
from howler.datastore.exceptions import SearchException
|
|
9
16
|
from howler.odm.models.dossier import Dossier
|
|
10
17
|
from howler.odm.models.user import User
|
|
18
|
+
from howler.services import lucene_service
|
|
11
19
|
|
|
12
20
|
logger = get_logger(__file__)
|
|
13
21
|
|
|
22
|
+
# Define which fields are allowed to be updated in a dossier, preventing unauthorized modification of sensitive fields
|
|
14
23
|
PERMITTED_KEYS = {
|
|
15
24
|
"title",
|
|
16
25
|
"query",
|
|
@@ -22,7 +31,14 @@ PERMITTED_KEYS = {
|
|
|
22
31
|
|
|
23
32
|
|
|
24
33
|
def exists(dossier_id: str) -> bool:
|
|
25
|
-
"""
|
|
34
|
+
"""Check if a dossier exists in the datastore.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
dossier_id: Unique identifier for the dossier
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if the dossier exists, False otherwise
|
|
41
|
+
"""
|
|
26
42
|
return datastore().dossier.exists(dossier_id)
|
|
27
43
|
|
|
28
44
|
|
|
@@ -31,15 +47,45 @@ def get_dossier(
|
|
|
31
47
|
as_odm: bool = False,
|
|
32
48
|
version: bool = False,
|
|
33
49
|
) -> Dossier:
|
|
34
|
-
"""
|
|
50
|
+
"""Retrieve a dossier from the datastore.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
id: Unique identifier for the dossier
|
|
54
|
+
as_odm: Whether to return as ODM object (True) or dictionary (False)
|
|
55
|
+
version: Whether to include version information in the response
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dossier object or dictionary containing dossier data
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
NotFoundException: If the dossier doesn't exist
|
|
62
|
+
"""
|
|
35
63
|
return datastore().dossier.get_if_exists(key=id, as_obj=as_odm, version=version)
|
|
36
64
|
|
|
37
65
|
|
|
38
66
|
def create_dossier(dossier_data: Optional[Any], username: str) -> Dossier: # noqa: C901
|
|
39
|
-
"Create a dossier
|
|
67
|
+
"""Create a new dossier in the datastore.
|
|
68
|
+
|
|
69
|
+
This function validates the input data, ensures the query is valid by testing it
|
|
70
|
+
against the hit collection, and creates a new dossier with the specified parameters.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
dossier_data: Dictionary containing dossier configuration data
|
|
74
|
+
username: Username of the user creating the dossier
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Newly created Dossier object
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
InvalidDataException: If data format is invalid, required fields are missing,
|
|
81
|
+
or the query is invalid
|
|
82
|
+
HowlerException: If there's an error during dossier creation
|
|
83
|
+
"""
|
|
84
|
+
# Validate input data format
|
|
40
85
|
if not isinstance(dossier_data, dict):
|
|
41
86
|
raise InvalidDataException("Invalid data format")
|
|
42
87
|
|
|
88
|
+
# Validate required fields for dossier creation
|
|
43
89
|
if "title" not in dossier_data:
|
|
44
90
|
raise InvalidDataException("You must specify a title when creating a dossier.")
|
|
45
91
|
|
|
@@ -52,7 +98,8 @@ def create_dossier(dossier_data: Optional[Any], username: str) -> Dossier: # no
|
|
|
52
98
|
storage = datastore()
|
|
53
99
|
|
|
54
100
|
try:
|
|
55
|
-
#
|
|
101
|
+
# Validate the Lucene query by attempting to search with it
|
|
102
|
+
# This ensures the query syntax is correct before saving the dossier
|
|
56
103
|
if query := dossier_data.get("query", None):
|
|
57
104
|
storage.hit.search(query)
|
|
58
105
|
|
|
@@ -61,59 +108,145 @@ def create_dossier(dossier_data: Optional[Any], username: str) -> Dossier: # no
|
|
|
61
108
|
|
|
62
109
|
dossier = Dossier(dossier_data)
|
|
63
110
|
|
|
111
|
+
# Validate pivot configurations to ensure no duplicate mapping keys
|
|
64
112
|
for pivot in dossier.pivots:
|
|
65
113
|
if len(pivot.mappings) != len(set(mapping.key for mapping in pivot.mappings)):
|
|
66
114
|
raise InvalidDataException("One of your pivots has duplicate keys set.")
|
|
67
115
|
|
|
116
|
+
# Ensure the owner is set to the current user (security measure)
|
|
68
117
|
dossier.owner = username
|
|
69
118
|
|
|
119
|
+
# Save the dossier to the datastore
|
|
70
120
|
storage.dossier.save(dossier.dossier_id, dossier)
|
|
71
121
|
|
|
122
|
+
# Commit the transaction to persist changes
|
|
72
123
|
storage.dossier.commit()
|
|
73
124
|
|
|
74
125
|
return dossier
|
|
75
126
|
except SearchException:
|
|
127
|
+
# Handle invalid Lucene query syntax
|
|
76
128
|
raise InvalidDataException("You must use a valid query when creating a dossier.")
|
|
77
129
|
except HowlerException as e:
|
|
130
|
+
# Handle other application-specific errors
|
|
78
131
|
raise InvalidDataException(str(e))
|
|
79
132
|
|
|
80
133
|
|
|
81
134
|
def update_dossier(dossier_id: str, dossier_data: dict[str, Any], user: User) -> Dossier: # noqa: C901
|
|
82
|
-
"""Update one or more properties of
|
|
135
|
+
"""Update one or more properties of a dossier in the database.
|
|
136
|
+
|
|
137
|
+
This function enforces access control rules and validates data before updating.
|
|
138
|
+
Personal dossiers can only be updated by their owners or admins.
|
|
139
|
+
Global dossiers can only be updated by their owners or admins.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
dossier_id: Unique identifier of the dossier to update
|
|
143
|
+
dossier_data: Dictionary containing fields to update
|
|
144
|
+
user: User object representing the requesting user
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Updated Dossier object
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
NotFoundException: If the dossier doesn't exist
|
|
151
|
+
InvalidDataException: If invalid fields are provided or data is malformed
|
|
152
|
+
ForbiddenException: If user lacks permission to update the dossier
|
|
153
|
+
"""
|
|
154
|
+
# Verify the dossier exists before attempting to update
|
|
83
155
|
if not exists(dossier_id):
|
|
84
156
|
raise NotFoundException(f"Dossier with id '{dossier_id}' does not exist.")
|
|
85
157
|
|
|
158
|
+
# Validate that only permitted fields are being updated
|
|
159
|
+
# This prevents unauthorized modification of sensitive fields
|
|
86
160
|
if set(dossier_data.keys()) - PERMITTED_KEYS:
|
|
87
161
|
raise InvalidDataException(f"Only {', '.join(PERMITTED_KEYS)} can be updated.")
|
|
88
162
|
|
|
89
163
|
storage = datastore()
|
|
90
164
|
|
|
165
|
+
# Retrieve the existing dossier for access control checks
|
|
91
166
|
existing_dossier: Dossier = get_dossier(dossier_id, as_odm=True)
|
|
167
|
+
|
|
168
|
+
# Enforce access control for personal dossiers
|
|
169
|
+
# Only the owner or admin users can modify personal dossiers
|
|
92
170
|
if existing_dossier.type == "personal" and existing_dossier.owner != user.uname and "admin" not in user.type:
|
|
93
171
|
raise ForbiddenException("You cannot update a personal dossier that is not owned by you.")
|
|
94
172
|
|
|
173
|
+
# Enforce access control for global dossiers
|
|
174
|
+
# Only the owner or admin users can modify global dossiers
|
|
95
175
|
if existing_dossier.type == "global" and existing_dossier.owner != user.uname and "admin" not in user.type:
|
|
96
176
|
raise ForbiddenException("Only the owner of a dossier and administrators can edit a global dossier.")
|
|
97
177
|
|
|
178
|
+
# Validate pivot configurations if they're being updated
|
|
179
|
+
# Ensure no duplicate mapping keys exist within any pivot
|
|
98
180
|
if "pivots" in dossier_data:
|
|
99
181
|
for pivot in dossier_data["pivots"]:
|
|
100
182
|
if len(pivot["mappings"]) != len(set(mapping["key"] for mapping in pivot["mappings"])):
|
|
101
183
|
raise InvalidDataException("One of your pivots has duplicate keys set.")
|
|
102
184
|
|
|
103
185
|
try:
|
|
186
|
+
# Validate the Lucene query if it's being updated
|
|
104
187
|
if "query" in dossier_data:
|
|
105
|
-
#
|
|
188
|
+
# Test the query against the hit index to ensure it's valid
|
|
106
189
|
storage.hit.search(dossier_data["query"])
|
|
107
190
|
|
|
191
|
+
# Merge the new data with existing dossier data
|
|
108
192
|
new_data = Dossier(merge({}, existing_dossier.as_primitives(), dossier_data))
|
|
109
193
|
|
|
110
194
|
storage.dossier.save(dossier_id, new_data)
|
|
111
195
|
|
|
196
|
+
# Commit the transaction to persist changes
|
|
112
197
|
storage.dossier.commit()
|
|
113
198
|
|
|
114
199
|
return new_data
|
|
115
200
|
except SearchException:
|
|
201
|
+
# Handle invalid Lucene query syntax
|
|
116
202
|
raise InvalidDataException("You must use a valid query when updating a dossier.")
|
|
117
203
|
except (HowlerException, TypeError) as e:
|
|
204
|
+
# Log the error for debugging purposes
|
|
118
205
|
logger.exception("Error when updating dossier.")
|
|
206
|
+
# Provide a user-friendly error message while preserving the original exception
|
|
119
207
|
raise InvalidDataException("We were unable to update the dossier.", cause=e) from e
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_matching_dossiers(hit: dict[str, Any], dossiers: Optional[list[dict[str, Any]]] = None):
|
|
211
|
+
"""Get a list of dossiers that match a specific security alert/hit.
|
|
212
|
+
|
|
213
|
+
This function evaluates each dossier's query against the provided hit data
|
|
214
|
+
to determine which dossiers are relevant to the security event.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
hit: Dictionary containing security alert/hit data to match against
|
|
218
|
+
dossiers: Optional list of dossiers to check. If None, all dossiers
|
|
219
|
+
will be retrieved from the datastore
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
List of dossier dictionaries that match the provided hit
|
|
223
|
+
|
|
224
|
+
Note:
|
|
225
|
+
This function uses Lucene query matching to determine relevance.
|
|
226
|
+
Dossiers with no query are assumed to match all hits.
|
|
227
|
+
"""
|
|
228
|
+
# Retrieve all dossiers if none provided
|
|
229
|
+
if dossiers is None:
|
|
230
|
+
dossiers: list[dict[str, Any]] = datastore().dossier.search(
|
|
231
|
+
"dossier_id:*",
|
|
232
|
+
as_obj=False,
|
|
233
|
+
# TODO: Eventually implement caching here
|
|
234
|
+
rows=1000,
|
|
235
|
+
)["items"]
|
|
236
|
+
|
|
237
|
+
matching_dossiers: list[dict[str, Any]] = []
|
|
238
|
+
|
|
239
|
+
# Evaluate each dossier against the hit data
|
|
240
|
+
for dossier in cast(list[dict[str, Any]], dossiers):
|
|
241
|
+
# Dossiers without queries match all hits by default
|
|
242
|
+
# This allows for catch-all dossiers that collect all security events
|
|
243
|
+
if "query" not in dossier or dossier["query"] is None:
|
|
244
|
+
matching_dossiers.append(dossier)
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Use Lucene service to check if the hit matches the dossier's query
|
|
248
|
+
# This determines if the security event is relevant to this investigation
|
|
249
|
+
if lucene_service.match(dossier["query"], hit):
|
|
250
|
+
matching_dossiers.append(dossier)
|
|
251
|
+
|
|
252
|
+
return matching_dossiers
|