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.

@@ -1,4 +1,11 @@
1
- from typing import Any, Optional
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
- """Returns true if the analytic_id is already in use."""
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
- """Return analytic object as either an ODM or Dict"""
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
- # Make sure the query is valid
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 an analytic in the database."""
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
- # Make sure the query is valid
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