cyvest 0.1.0__py3-none-any.whl → 5.1.3__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.
- cyvest/__init__.py +48 -38
- cyvest/cli.py +487 -0
- cyvest/compare.py +318 -0
- cyvest/cyvest.py +1431 -0
- cyvest/investigation.py +1682 -0
- cyvest/io_rich.py +1153 -0
- cyvest/io_schema.py +35 -0
- cyvest/io_serialization.py +465 -0
- cyvest/io_visualization.py +358 -0
- cyvest/keys.py +237 -0
- cyvest/level_score_rules.py +78 -0
- cyvest/levels.py +175 -0
- cyvest/model.py +595 -0
- cyvest/model_enums.py +69 -0
- cyvest/model_schema.py +164 -0
- cyvest/proxies.py +595 -0
- cyvest/score.py +473 -0
- cyvest/shared.py +508 -0
- cyvest/stats.py +291 -0
- cyvest/ulid.py +36 -0
- cyvest-5.1.3.dist-info/METADATA +632 -0
- cyvest-5.1.3.dist-info/RECORD +24 -0
- {cyvest-0.1.0.dist-info → cyvest-5.1.3.dist-info}/WHEEL +1 -2
- cyvest-5.1.3.dist-info/entry_points.txt +3 -0
- cyvest/builder.py +0 -182
- cyvest/check_tree.py +0 -117
- cyvest/models.py +0 -785
- cyvest/observable_registry.py +0 -69
- cyvest/report_render.py +0 -306
- cyvest/report_serialization.py +0 -237
- cyvest/visitors.py +0 -332
- cyvest-0.1.0.dist-info/METADATA +0 -110
- cyvest-0.1.0.dist-info/RECORD +0 -13
- cyvest-0.1.0.dist-info/licenses/LICENSE +0 -21
- cyvest-0.1.0.dist-info/top_level.txt +0 -1
cyvest/proxies.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-only proxy wrappers for Cyvest model objects.
|
|
3
|
+
|
|
4
|
+
These lightweight proxies expose investigation state to callers without allowing
|
|
5
|
+
them to mutate the underlying dataclasses directly. Each proxy stores only the
|
|
6
|
+
object key and looks up the live model instance inside the investigation on
|
|
7
|
+
every attribute access, ensuring that the latest score engine computations are
|
|
8
|
+
visible while keeping mutations confined to Cyvest services.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from cyvest import keys
|
|
18
|
+
from cyvest.levels import Level
|
|
19
|
+
from cyvest.model import (
|
|
20
|
+
Check,
|
|
21
|
+
Enrichment,
|
|
22
|
+
Observable,
|
|
23
|
+
ObservableLink,
|
|
24
|
+
ObservableType,
|
|
25
|
+
Relationship,
|
|
26
|
+
Tag,
|
|
27
|
+
Taxonomy,
|
|
28
|
+
ThreatIntel,
|
|
29
|
+
)
|
|
30
|
+
from cyvest.model_enums import PropagationMode, RelationshipDirection, RelationshipType
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from cyvest.investigation import Investigation
|
|
34
|
+
|
|
35
|
+
_T = TypeVar("_T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ModelNotFoundError(RuntimeError):
|
|
39
|
+
"""Raised when a proxy points to an object that no longer exists."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ReadOnlyProxy(Generic[_T]):
|
|
43
|
+
"""Base helper for wrapping model objects."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("__investigation", "__key")
|
|
46
|
+
|
|
47
|
+
def __init__(self, investigation: Investigation, key: str) -> None:
|
|
48
|
+
object.__setattr__(self, "_ReadOnlyProxy__investigation", investigation)
|
|
49
|
+
object.__setattr__(self, "_ReadOnlyProxy__key", key)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def key(self) -> str:
|
|
53
|
+
"""Return the stable object key."""
|
|
54
|
+
return object.__getattribute__(self, "_ReadOnlyProxy__key")
|
|
55
|
+
|
|
56
|
+
def _get_investigation(self) -> Investigation:
|
|
57
|
+
return object.__getattribute__(self, "_ReadOnlyProxy__investigation")
|
|
58
|
+
|
|
59
|
+
def _resolve(self) -> _T: # pragma: no cover - overridden in subclasses
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def _read_attr(self, name: str):
|
|
63
|
+
"""Resolve and deep-copy a public attribute from the model."""
|
|
64
|
+
model = self._resolve()
|
|
65
|
+
value = getattr(model, name)
|
|
66
|
+
if callable(value):
|
|
67
|
+
raise AttributeError(
|
|
68
|
+
f"Method '{name}' is not available on read-only proxies. Use Cyvest services for mutations."
|
|
69
|
+
)
|
|
70
|
+
return deepcopy(value)
|
|
71
|
+
|
|
72
|
+
def __setattr__(self, name: str, value) -> None: # noqa: ANN001
|
|
73
|
+
"""Prevent attribute mutation."""
|
|
74
|
+
raise AttributeError(f"{self.__class__.__name__} is read-only. Use Cyvest APIs to modify investigation data.")
|
|
75
|
+
|
|
76
|
+
def __delattr__(self, name: str) -> None:
|
|
77
|
+
raise AttributeError(f"{self.__class__.__name__} is read-only. Use Cyvest APIs to modify investigation data.")
|
|
78
|
+
|
|
79
|
+
def _call_readonly(self, method: str, *args, **kwargs):
|
|
80
|
+
"""Invoke a model method in read-only mode and deepcopy the result."""
|
|
81
|
+
model = self._resolve()
|
|
82
|
+
attr = getattr(model, method, None)
|
|
83
|
+
if attr is None or not callable(attr):
|
|
84
|
+
raise AttributeError(f"{self.__class__.__name__} exposes no method '{method}'")
|
|
85
|
+
return deepcopy(attr(*args, **kwargs))
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
model = self._resolve()
|
|
89
|
+
return f"{self.__class__.__name__}(key={self.key!r}, type={model.__class__.__name__})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ObservableProxy(_ReadOnlyProxy[Observable]):
|
|
93
|
+
"""Read-only proxy over an observable."""
|
|
94
|
+
|
|
95
|
+
def _resolve(self):
|
|
96
|
+
observable = self._get_investigation().get_observable(self.key)
|
|
97
|
+
if observable is None:
|
|
98
|
+
raise ModelNotFoundError(f"Observable '{self.key}' no longer exists in this investigation.")
|
|
99
|
+
return observable
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def obs_type(self) -> ObservableType | str:
|
|
103
|
+
return self._read_attr("obs_type")
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def value(self) -> str:
|
|
107
|
+
return self._read_attr("value")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def internal(self) -> bool:
|
|
111
|
+
return self._read_attr("internal")
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def whitelisted(self) -> bool:
|
|
115
|
+
return self._read_attr("whitelisted")
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def comment(self) -> str:
|
|
119
|
+
return self._read_attr("comment")
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def extra(self) -> dict[str, Any]:
|
|
123
|
+
return self._read_attr("extra")
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def score(self) -> Decimal:
|
|
127
|
+
return self._read_attr("score")
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def score_display(self) -> str:
|
|
131
|
+
return self._read_attr("score_display")
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def level(self) -> Level:
|
|
135
|
+
return self._read_attr("level")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def threat_intels(self) -> list[ThreatIntel]:
|
|
139
|
+
return self._read_attr("threat_intels")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def relationships(self) -> list[Relationship]:
|
|
143
|
+
return self._read_attr("relationships")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def check_links(self) -> list[str]:
|
|
147
|
+
"""Checks that currently link to this observable."""
|
|
148
|
+
return self._read_attr("check_links")
|
|
149
|
+
|
|
150
|
+
def get_audit_events(self) -> tuple:
|
|
151
|
+
"""Return audit events for this observable."""
|
|
152
|
+
events = self._get_investigation().get_audit_events(object_type="observable", object_key=self.key)
|
|
153
|
+
return tuple(events)
|
|
154
|
+
|
|
155
|
+
def update_metadata(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
comment: str | None = None,
|
|
159
|
+
extra: dict[str, Any] | None = None,
|
|
160
|
+
internal: bool | None = None,
|
|
161
|
+
whitelisted: bool | None = None,
|
|
162
|
+
merge_extra: bool = True,
|
|
163
|
+
) -> ObservableProxy:
|
|
164
|
+
"""
|
|
165
|
+
Update mutable metadata fields on the observable.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
comment: Optional comment override.
|
|
169
|
+
extra: Dictionary to merge into (or replace) ``extra``.
|
|
170
|
+
internal: Whether the observable is an internal asset.
|
|
171
|
+
whitelisted: Whether the observable is whitelisted.
|
|
172
|
+
merge_extra: When False, replaces ``extra`` entirely.
|
|
173
|
+
"""
|
|
174
|
+
updates: dict[str, Any] = {}
|
|
175
|
+
if comment is not None:
|
|
176
|
+
updates["comment"] = comment
|
|
177
|
+
if extra is not None:
|
|
178
|
+
updates["extra"] = extra
|
|
179
|
+
if internal is not None:
|
|
180
|
+
updates["internal"] = internal
|
|
181
|
+
if whitelisted is not None:
|
|
182
|
+
updates["whitelisted"] = whitelisted
|
|
183
|
+
|
|
184
|
+
if not updates:
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
dict_merge = {"extra": merge_extra} if extra is not None else None
|
|
188
|
+
self._get_investigation().update_model_metadata("observable", self.key, updates, dict_merge=dict_merge)
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def set_level(self, level: Level, reason: str | None = None) -> ObservableProxy:
|
|
192
|
+
"""Set the level without changing score."""
|
|
193
|
+
observable = self._resolve()
|
|
194
|
+
self._get_investigation().apply_level_change(observable, level, reason=reason or "Manual level update")
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def with_ti(
|
|
198
|
+
self,
|
|
199
|
+
source: str,
|
|
200
|
+
score: Decimal | float,
|
|
201
|
+
comment: str = "",
|
|
202
|
+
extra: dict[str, Any] | None = None,
|
|
203
|
+
level: Level | None = None,
|
|
204
|
+
taxonomies: list[Taxonomy | dict[str, Any]] | None = None,
|
|
205
|
+
) -> ObservableProxy:
|
|
206
|
+
"""
|
|
207
|
+
Attach threat intelligence to this observable.
|
|
208
|
+
"""
|
|
209
|
+
observable = self._resolve()
|
|
210
|
+
ti_kwargs: dict[str, Any] = {
|
|
211
|
+
"source": source,
|
|
212
|
+
"observable_key": self.key,
|
|
213
|
+
"comment": comment,
|
|
214
|
+
"extra": extra or {},
|
|
215
|
+
"score": Decimal(str(score)),
|
|
216
|
+
"taxonomies": taxonomies or [],
|
|
217
|
+
}
|
|
218
|
+
if level is not None:
|
|
219
|
+
ti_kwargs["level"] = level
|
|
220
|
+
ti = ThreatIntel(**ti_kwargs)
|
|
221
|
+
self._get_investigation().add_threat_intel(ti, observable)
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
def with_ti_draft(self, draft: ThreatIntel) -> ThreatIntelProxy:
|
|
225
|
+
"""
|
|
226
|
+
Attach a threat intel draft to this observable.
|
|
227
|
+
"""
|
|
228
|
+
if not isinstance(draft, ThreatIntel):
|
|
229
|
+
raise TypeError("Threat intel draft must be a ThreatIntel instance.")
|
|
230
|
+
if draft.observable_key and draft.observable_key != self.key:
|
|
231
|
+
raise ValueError("Threat intel is already bound to a different observable.")
|
|
232
|
+
|
|
233
|
+
observable = self._resolve()
|
|
234
|
+
draft.observable_key = self.key
|
|
235
|
+
expected_key = keys.generate_threat_intel_key(draft.source, self.key)
|
|
236
|
+
if not draft.key or draft.key != expected_key:
|
|
237
|
+
draft.key = expected_key
|
|
238
|
+
|
|
239
|
+
result = self._get_investigation().add_threat_intel(draft, observable)
|
|
240
|
+
return ThreatIntelProxy(self._get_investigation(), result.key)
|
|
241
|
+
|
|
242
|
+
def relate_to(
|
|
243
|
+
self,
|
|
244
|
+
target: Observable | ObservableProxy | str,
|
|
245
|
+
relationship_type: RelationshipType,
|
|
246
|
+
direction: RelationshipDirection | None = None,
|
|
247
|
+
) -> ObservableProxy:
|
|
248
|
+
"""Create a relationship to another observable."""
|
|
249
|
+
if isinstance(target, ObservableProxy):
|
|
250
|
+
resolved_target: Observable | str = target.key
|
|
251
|
+
elif isinstance(target, Observable):
|
|
252
|
+
resolved_target = target
|
|
253
|
+
elif isinstance(target, str):
|
|
254
|
+
resolved_target = target
|
|
255
|
+
else:
|
|
256
|
+
raise TypeError("Target must be an observable key, ObservableProxy, or Observable instance.")
|
|
257
|
+
|
|
258
|
+
self._get_investigation().add_relationship(self.key, resolved_target, relationship_type, direction)
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def link_check(
|
|
262
|
+
self,
|
|
263
|
+
check: Check | CheckProxy | str,
|
|
264
|
+
*,
|
|
265
|
+
propagation_mode: PropagationMode = PropagationMode.LOCAL_ONLY,
|
|
266
|
+
) -> ObservableProxy:
|
|
267
|
+
"""Link this observable to a check."""
|
|
268
|
+
if isinstance(check, CheckProxy):
|
|
269
|
+
check_key = check.key
|
|
270
|
+
elif isinstance(check, Check):
|
|
271
|
+
check_key = check.key
|
|
272
|
+
elif isinstance(check, str):
|
|
273
|
+
check_key = check
|
|
274
|
+
else:
|
|
275
|
+
raise TypeError("Check must provide a key.")
|
|
276
|
+
|
|
277
|
+
self._get_investigation().link_check_observable(check_key, self.key, propagation_mode=propagation_mode)
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class CheckProxy(_ReadOnlyProxy[Check]):
|
|
282
|
+
"""Read-only proxy over a check."""
|
|
283
|
+
|
|
284
|
+
def _resolve(self):
|
|
285
|
+
check = self._get_investigation().get_check(self.key)
|
|
286
|
+
if check is None:
|
|
287
|
+
raise ModelNotFoundError(f"Check '{self.key}' no longer exists in this investigation.")
|
|
288
|
+
return check
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def check_name(self) -> str:
|
|
292
|
+
return self._read_attr("check_name")
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def description(self) -> str:
|
|
296
|
+
return self._read_attr("description")
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def comment(self) -> str:
|
|
300
|
+
return self._read_attr("comment")
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def extra(self) -> dict[str, Any]:
|
|
304
|
+
return self._read_attr("extra")
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def score(self) -> Decimal:
|
|
308
|
+
return self._read_attr("score")
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def score_display(self) -> str:
|
|
312
|
+
return self._read_attr("score_display")
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def level(self) -> Level:
|
|
316
|
+
return self._read_attr("level")
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def origin_investigation_id(self) -> str:
|
|
320
|
+
return self._read_attr("origin_investigation_id")
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def observable_links(self) -> list[ObservableLink]:
|
|
324
|
+
return self._read_attr("observable_links")
|
|
325
|
+
|
|
326
|
+
def get_audit_events(self) -> tuple:
|
|
327
|
+
"""Return audit events for this check."""
|
|
328
|
+
events = self._get_investigation().get_audit_events(object_type="check", object_key=self.key)
|
|
329
|
+
return tuple(events)
|
|
330
|
+
|
|
331
|
+
def update_metadata(
|
|
332
|
+
self,
|
|
333
|
+
*,
|
|
334
|
+
comment: str | None = None,
|
|
335
|
+
description: str | None = None,
|
|
336
|
+
extra: dict[str, Any] | None = None,
|
|
337
|
+
merge_extra: bool = True,
|
|
338
|
+
) -> CheckProxy:
|
|
339
|
+
"""Update mutable metadata on the check."""
|
|
340
|
+
updates: dict[str, Any] = {}
|
|
341
|
+
if comment is not None:
|
|
342
|
+
updates["comment"] = comment
|
|
343
|
+
if description is not None:
|
|
344
|
+
updates["description"] = description
|
|
345
|
+
if extra is not None:
|
|
346
|
+
updates["extra"] = extra
|
|
347
|
+
|
|
348
|
+
if not updates:
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
dict_merge = {"extra": merge_extra} if extra is not None else None
|
|
352
|
+
self._get_investigation().update_model_metadata("check", self.key, updates, dict_merge=dict_merge)
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def set_level(self, level: Level, reason: str | None = None) -> CheckProxy:
|
|
356
|
+
"""Set the level without changing score."""
|
|
357
|
+
check = self._resolve()
|
|
358
|
+
self._get_investigation().apply_level_change(check, level, reason=reason or "Manual level update")
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def tagged(self, *tags: Tag | TagProxy | str) -> CheckProxy:
|
|
362
|
+
"""Add this check to one or more tags (auto-creates tags from strings)."""
|
|
363
|
+
investigation = self._get_investigation()
|
|
364
|
+
for tag in tags:
|
|
365
|
+
if isinstance(tag, TagProxy):
|
|
366
|
+
tag_key = tag.key
|
|
367
|
+
elif isinstance(tag, Tag):
|
|
368
|
+
tag_key = tag.key
|
|
369
|
+
elif isinstance(tag, str):
|
|
370
|
+
# Auto-create tag if it doesn't exist
|
|
371
|
+
tag_key = keys.generate_tag_key(tag)
|
|
372
|
+
if investigation.get_tag(tag_key) is None:
|
|
373
|
+
investigation.add_tag(Tag(name=tag, checks=[], key=tag_key))
|
|
374
|
+
else:
|
|
375
|
+
raise TypeError("Tag must provide a key.")
|
|
376
|
+
|
|
377
|
+
investigation.add_check_to_tag(tag_key, self.key)
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
def link_observable(
|
|
381
|
+
self,
|
|
382
|
+
observable: Observable | ObservableProxy | str,
|
|
383
|
+
*,
|
|
384
|
+
propagation_mode: PropagationMode = PropagationMode.LOCAL_ONLY,
|
|
385
|
+
) -> CheckProxy:
|
|
386
|
+
"""Link an observable to this check."""
|
|
387
|
+
if isinstance(observable, ObservableProxy):
|
|
388
|
+
observable_key = observable.key
|
|
389
|
+
elif isinstance(observable, Observable):
|
|
390
|
+
observable_key = observable.key
|
|
391
|
+
elif isinstance(observable, str):
|
|
392
|
+
observable_key = observable
|
|
393
|
+
else:
|
|
394
|
+
raise TypeError("Observable must provide a key.")
|
|
395
|
+
|
|
396
|
+
self._get_investigation().link_check_observable(self.key, observable_key, propagation_mode=propagation_mode)
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def with_score(self, score: Decimal | float, reason: str = "") -> CheckProxy:
|
|
400
|
+
"""Update the check's score."""
|
|
401
|
+
check = self._resolve()
|
|
402
|
+
self._get_investigation().apply_score_change(check, Decimal(str(score)), reason=reason)
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class TagProxy(_ReadOnlyProxy[Tag]):
|
|
407
|
+
"""Read-only proxy over a tag."""
|
|
408
|
+
|
|
409
|
+
def _resolve(self):
|
|
410
|
+
tag = self._get_investigation().get_tag(self.key)
|
|
411
|
+
if tag is None:
|
|
412
|
+
raise ModelNotFoundError(f"Tag '{self.key}' no longer exists in this investigation.")
|
|
413
|
+
return tag
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def name(self) -> str:
|
|
417
|
+
return self._read_attr("name")
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def description(self) -> str:
|
|
421
|
+
return self._read_attr("description")
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def checks(self) -> list[Check]:
|
|
425
|
+
return self._read_attr("checks")
|
|
426
|
+
|
|
427
|
+
def get_direct_score(self):
|
|
428
|
+
"""Return the direct score (checks in this tag only, no hierarchy)."""
|
|
429
|
+
return self._call_readonly("get_direct_score")
|
|
430
|
+
|
|
431
|
+
def get_direct_level(self):
|
|
432
|
+
"""Return the direct level (from direct score only, no hierarchy)."""
|
|
433
|
+
return self._call_readonly("get_direct_level")
|
|
434
|
+
|
|
435
|
+
def get_aggregated_score(self):
|
|
436
|
+
"""Return the aggregated score including all descendant tags."""
|
|
437
|
+
tag = self._resolve()
|
|
438
|
+
return self._get_investigation().get_tag_aggregated_score(tag.name)
|
|
439
|
+
|
|
440
|
+
def get_aggregated_level(self):
|
|
441
|
+
"""Return the aggregated level including all descendant tags."""
|
|
442
|
+
tag = self._resolve()
|
|
443
|
+
return self._get_investigation().get_tag_aggregated_level(tag.name)
|
|
444
|
+
|
|
445
|
+
def add_check(self, check: Check | CheckProxy | str) -> TagProxy:
|
|
446
|
+
"""Add a check to this tag."""
|
|
447
|
+
if isinstance(check, CheckProxy):
|
|
448
|
+
check_key = check.key
|
|
449
|
+
elif isinstance(check, Check):
|
|
450
|
+
check_key = check.key
|
|
451
|
+
elif isinstance(check, str):
|
|
452
|
+
check_key = check
|
|
453
|
+
else:
|
|
454
|
+
raise TypeError("Check must provide a key.")
|
|
455
|
+
|
|
456
|
+
self._get_investigation().add_check_to_tag(self.key, check_key)
|
|
457
|
+
return self
|
|
458
|
+
|
|
459
|
+
def __enter__(self) -> TagProxy:
|
|
460
|
+
"""Context manager entry returning self."""
|
|
461
|
+
return self
|
|
462
|
+
|
|
463
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
464
|
+
"""Context manager exit (no-op)."""
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
def update_metadata(self, *, description: str | None = None) -> TagProxy:
|
|
468
|
+
"""Update mutable metadata on the tag."""
|
|
469
|
+
if description is None:
|
|
470
|
+
return self
|
|
471
|
+
self._get_investigation().update_model_metadata("tag", self.key, {"description": description})
|
|
472
|
+
return self
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class ThreatIntelProxy(_ReadOnlyProxy[ThreatIntel]):
|
|
476
|
+
"""Read-only proxy over a threat intel entry."""
|
|
477
|
+
|
|
478
|
+
def _resolve(self):
|
|
479
|
+
ti = self._get_investigation().get_threat_intel(self.key)
|
|
480
|
+
if ti is None:
|
|
481
|
+
raise ModelNotFoundError(f"Threat intel '{self.key}' no longer exists in this investigation.")
|
|
482
|
+
return ti
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def source(self) -> str:
|
|
486
|
+
return self._read_attr("source")
|
|
487
|
+
|
|
488
|
+
@property
|
|
489
|
+
def observable_key(self) -> str:
|
|
490
|
+
return self._read_attr("observable_key")
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def comment(self) -> str:
|
|
494
|
+
return self._read_attr("comment")
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def extra(self) -> dict[str, Any]:
|
|
498
|
+
return self._read_attr("extra")
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def score(self) -> Decimal:
|
|
502
|
+
return self._read_attr("score")
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def score_display(self) -> str:
|
|
506
|
+
return self._read_attr("score_display")
|
|
507
|
+
|
|
508
|
+
@property
|
|
509
|
+
def level(self) -> Level:
|
|
510
|
+
return self._read_attr("level")
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def taxonomies(self) -> list[Taxonomy]:
|
|
514
|
+
return self._read_attr("taxonomies")
|
|
515
|
+
|
|
516
|
+
def add_taxonomy(self, *, level: Level, name: str, value: str) -> ThreatIntelProxy:
|
|
517
|
+
"""Add or replace a taxonomy by name."""
|
|
518
|
+
taxonomy = Taxonomy(level=level, name=name, value=value)
|
|
519
|
+
self._get_investigation().add_threat_intel_taxonomy(self.key, taxonomy)
|
|
520
|
+
return self
|
|
521
|
+
|
|
522
|
+
def remove_taxonomy(self, name: str) -> ThreatIntelProxy:
|
|
523
|
+
"""Remove a taxonomy by name."""
|
|
524
|
+
self._get_investigation().remove_threat_intel_taxonomy(self.key, name)
|
|
525
|
+
return self
|
|
526
|
+
|
|
527
|
+
def update_metadata(
|
|
528
|
+
self,
|
|
529
|
+
*,
|
|
530
|
+
comment: str | None = None,
|
|
531
|
+
extra: dict[str, Any] | None = None,
|
|
532
|
+
merge_extra: bool = True,
|
|
533
|
+
) -> ThreatIntelProxy:
|
|
534
|
+
"""Update mutable metadata on the threat intel entry."""
|
|
535
|
+
updates: dict[str, Any] = {}
|
|
536
|
+
if comment is not None:
|
|
537
|
+
updates["comment"] = comment
|
|
538
|
+
if extra is not None:
|
|
539
|
+
updates["extra"] = extra
|
|
540
|
+
|
|
541
|
+
if not updates:
|
|
542
|
+
return self
|
|
543
|
+
|
|
544
|
+
dict_merge = {"extra": merge_extra} if extra is not None else None
|
|
545
|
+
self._get_investigation().update_model_metadata("threat_intel", self.key, updates, dict_merge=dict_merge)
|
|
546
|
+
return self
|
|
547
|
+
|
|
548
|
+
def set_level(self, level: Level, reason: str | None = None) -> ThreatIntelProxy:
|
|
549
|
+
"""Set the level without changing score."""
|
|
550
|
+
ti = self._resolve()
|
|
551
|
+
self._get_investigation().apply_level_change(ti, level, reason=reason or "Manual level update")
|
|
552
|
+
return self
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class EnrichmentProxy(_ReadOnlyProxy[Enrichment]):
|
|
556
|
+
"""Read-only proxy over an enrichment."""
|
|
557
|
+
|
|
558
|
+
def _resolve(self):
|
|
559
|
+
enrichment = self._get_investigation().get_enrichment(self.key)
|
|
560
|
+
if enrichment is None:
|
|
561
|
+
raise ModelNotFoundError(f"Enrichment '{self.key}' no longer exists in this investigation.")
|
|
562
|
+
return enrichment
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def name(self) -> str:
|
|
566
|
+
return self._read_attr("name")
|
|
567
|
+
|
|
568
|
+
@property
|
|
569
|
+
def data(self) -> dict[str, Any]:
|
|
570
|
+
return self._read_attr("data")
|
|
571
|
+
|
|
572
|
+
@property
|
|
573
|
+
def context(self) -> str:
|
|
574
|
+
return self._read_attr("context")
|
|
575
|
+
|
|
576
|
+
def update_metadata(
|
|
577
|
+
self,
|
|
578
|
+
*,
|
|
579
|
+
context: str | None = None,
|
|
580
|
+
data: dict[str, Any] | None = None,
|
|
581
|
+
merge_data: bool = True,
|
|
582
|
+
) -> EnrichmentProxy:
|
|
583
|
+
"""Update mutable metadata on the enrichment."""
|
|
584
|
+
updates: dict[str, Any] = {}
|
|
585
|
+
if context is not None:
|
|
586
|
+
updates["context"] = context
|
|
587
|
+
if data is not None:
|
|
588
|
+
updates["data"] = data
|
|
589
|
+
|
|
590
|
+
if not updates:
|
|
591
|
+
return self
|
|
592
|
+
|
|
593
|
+
dict_merge = {"data": merge_data} if data is not None else None
|
|
594
|
+
self._get_investigation().update_model_metadata("enrichment", self.key, updates, dict_merge=dict_merge)
|
|
595
|
+
return self
|