cyvest 4.4.0__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 cyvest might be problematic. Click here for more details.

cyvest/proxies.py ADDED
@@ -0,0 +1,582 @@
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
+ Container,
22
+ Enrichment,
23
+ Observable,
24
+ ObservableLink,
25
+ ObservableType,
26
+ Relationship,
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 with_ti(
192
+ self,
193
+ source: str,
194
+ score: Decimal | float,
195
+ comment: str = "",
196
+ extra: dict[str, Any] | None = None,
197
+ level: Level | None = None,
198
+ taxonomies: list[Taxonomy | dict[str, Any]] | None = None,
199
+ ) -> ObservableProxy:
200
+ """
201
+ Attach threat intelligence to this observable.
202
+ """
203
+ observable = self._resolve()
204
+ ti_kwargs: dict[str, Any] = {
205
+ "source": source,
206
+ "observable_key": self.key,
207
+ "comment": comment,
208
+ "extra": extra or {},
209
+ "score": Decimal(str(score)),
210
+ "taxonomies": taxonomies or [],
211
+ }
212
+ if level is not None:
213
+ ti_kwargs["level"] = level
214
+ ti = ThreatIntel(**ti_kwargs)
215
+ self._get_investigation().add_threat_intel(ti, observable)
216
+ return self
217
+
218
+ def with_ti_draft(self, draft: ThreatIntel) -> ThreatIntelProxy:
219
+ """
220
+ Attach a threat intel draft to this observable.
221
+ """
222
+ if not isinstance(draft, ThreatIntel):
223
+ raise TypeError("Threat intel draft must be a ThreatIntel instance.")
224
+ if draft.observable_key and draft.observable_key != self.key:
225
+ raise ValueError("Threat intel is already bound to a different observable.")
226
+
227
+ observable = self._resolve()
228
+ draft.observable_key = self.key
229
+ expected_key = keys.generate_threat_intel_key(draft.source, self.key)
230
+ if not draft.key or draft.key != expected_key:
231
+ draft.key = expected_key
232
+
233
+ result = self._get_investigation().add_threat_intel(draft, observable)
234
+ return ThreatIntelProxy(self._get_investigation(), result.key)
235
+
236
+ def relate_to(
237
+ self,
238
+ target: Observable | ObservableProxy | str,
239
+ relationship_type: RelationshipType,
240
+ direction: RelationshipDirection | None = None,
241
+ ) -> ObservableProxy:
242
+ """Create a relationship to another observable."""
243
+ if isinstance(target, ObservableProxy):
244
+ resolved_target: Observable | str = target.key
245
+ elif isinstance(target, Observable):
246
+ resolved_target = target
247
+ elif isinstance(target, str):
248
+ resolved_target = target
249
+ else:
250
+ raise TypeError("Target must be an observable key, ObservableProxy, or Observable instance.")
251
+
252
+ self._get_investigation().add_relationship(self.key, resolved_target, relationship_type, direction)
253
+ return self
254
+
255
+ def link_check(
256
+ self,
257
+ check: Check | CheckProxy | str,
258
+ *,
259
+ propagation_mode: PropagationMode = PropagationMode.LOCAL_ONLY,
260
+ ) -> ObservableProxy:
261
+ """Link this observable to a check."""
262
+ if isinstance(check, CheckProxy):
263
+ check_key = check.key
264
+ elif isinstance(check, Check):
265
+ check_key = check.key
266
+ elif isinstance(check, str):
267
+ check_key = check
268
+ else:
269
+ raise TypeError("Check must provide a key.")
270
+
271
+ self._get_investigation().link_check_observable(check_key, self.key, propagation_mode=propagation_mode)
272
+ return self
273
+
274
+
275
+ class CheckProxy(_ReadOnlyProxy[Check]):
276
+ """Read-only proxy over a check."""
277
+
278
+ def _resolve(self):
279
+ check = self._get_investigation().get_check(self.key)
280
+ if check is None:
281
+ raise ModelNotFoundError(f"Check '{self.key}' no longer exists in this investigation.")
282
+ return check
283
+
284
+ @property
285
+ def check_id(self) -> str:
286
+ return self._read_attr("check_id")
287
+
288
+ @property
289
+ def scope(self) -> str:
290
+ return self._read_attr("scope")
291
+
292
+ @property
293
+ def description(self) -> str:
294
+ return self._read_attr("description")
295
+
296
+ @property
297
+ def comment(self) -> str:
298
+ return self._read_attr("comment")
299
+
300
+ @property
301
+ def extra(self) -> dict[str, Any]:
302
+ return self._read_attr("extra")
303
+
304
+ @property
305
+ def score(self) -> Decimal:
306
+ return self._read_attr("score")
307
+
308
+ @property
309
+ def score_display(self) -> str:
310
+ return self._read_attr("score_display")
311
+
312
+ @property
313
+ def level(self) -> Level:
314
+ return self._read_attr("level")
315
+
316
+ @property
317
+ def origin_investigation_id(self) -> str:
318
+ return self._read_attr("origin_investigation_id")
319
+
320
+ @property
321
+ def observable_links(self) -> list[ObservableLink]:
322
+ return self._read_attr("observable_links")
323
+
324
+ def get_audit_events(self) -> tuple:
325
+ """Return audit events for this check."""
326
+ events = self._get_investigation().get_audit_events(object_type="check", object_key=self.key)
327
+ return tuple(events)
328
+
329
+ def update_metadata(
330
+ self,
331
+ *,
332
+ comment: str | None = None,
333
+ description: str | None = None,
334
+ extra: dict[str, Any] | None = None,
335
+ merge_extra: bool = True,
336
+ ) -> CheckProxy:
337
+ """Update mutable metadata on the check."""
338
+ updates: dict[str, Any] = {}
339
+ if comment is not None:
340
+ updates["comment"] = comment
341
+ if description is not None:
342
+ updates["description"] = description
343
+ if extra is not None:
344
+ updates["extra"] = extra
345
+
346
+ if not updates:
347
+ return self
348
+
349
+ dict_merge = {"extra": merge_extra} if extra is not None else None
350
+ self._get_investigation().update_model_metadata("check", self.key, updates, dict_merge=dict_merge)
351
+ return self
352
+
353
+ def in_container(self, container: Container | ContainerProxy | str) -> CheckProxy:
354
+ """Add this check to a container."""
355
+ if isinstance(container, ContainerProxy):
356
+ container_key = container.key
357
+ elif isinstance(container, Container):
358
+ container_key = container.key
359
+ elif isinstance(container, str):
360
+ container_key = container
361
+ else:
362
+ raise TypeError("Container must provide a key.")
363
+
364
+ self._get_investigation().add_check_to_container(container_key, self.key)
365
+ return self
366
+
367
+ def link_observable(
368
+ self,
369
+ observable: Observable | ObservableProxy | str,
370
+ *,
371
+ propagation_mode: PropagationMode = PropagationMode.LOCAL_ONLY,
372
+ ) -> CheckProxy:
373
+ """Link an observable to this check."""
374
+ if isinstance(observable, ObservableProxy):
375
+ observable_key = observable.key
376
+ elif isinstance(observable, Observable):
377
+ observable_key = observable.key
378
+ elif isinstance(observable, str):
379
+ observable_key = observable
380
+ else:
381
+ raise TypeError("Observable must provide a key.")
382
+
383
+ self._get_investigation().link_check_observable(self.key, observable_key, propagation_mode=propagation_mode)
384
+ return self
385
+
386
+ def with_score(self, score: Decimal | float, reason: str = "") -> CheckProxy:
387
+ """Update the check's score."""
388
+ check = self._resolve()
389
+ self._get_investigation().apply_score_change(check, Decimal(str(score)), reason=reason)
390
+ return self
391
+
392
+
393
+ class ContainerProxy(_ReadOnlyProxy[Container]):
394
+ """Read-only proxy over a container."""
395
+
396
+ def _resolve(self):
397
+ container = self._get_investigation().get_container(self.key)
398
+ if container is None:
399
+ raise ModelNotFoundError(f"Container '{self.key}' no longer exists in this investigation.")
400
+ return container
401
+
402
+ @property
403
+ def path(self) -> str:
404
+ return self._read_attr("path")
405
+
406
+ @property
407
+ def description(self) -> str:
408
+ return self._read_attr("description")
409
+
410
+ @property
411
+ def checks(self) -> list[Check]:
412
+ return self._read_attr("checks")
413
+
414
+ @property
415
+ def sub_containers(self) -> dict[str, Container]:
416
+ return self._read_attr("sub_containers")
417
+
418
+ def get_aggregated_score(self):
419
+ """Return the aggregated score copy."""
420
+ return self._call_readonly("get_aggregated_score")
421
+
422
+ def get_aggregated_level(self):
423
+ """Return the aggregated level copy."""
424
+ return self._call_readonly("get_aggregated_level")
425
+
426
+ def add_check(self, check: Check | CheckProxy | str) -> ContainerProxy:
427
+ """Add a check to this container."""
428
+ if isinstance(check, CheckProxy):
429
+ check_key = check.key
430
+ elif isinstance(check, Check):
431
+ check_key = check.key
432
+ elif isinstance(check, str):
433
+ check_key = check
434
+ else:
435
+ raise TypeError("Check must provide a key.")
436
+
437
+ self._get_investigation().add_check_to_container(self.key, check_key)
438
+ return self
439
+
440
+ def sub_container(self, path: str, description: str = "") -> ContainerProxy:
441
+ """Create a sub-container nested beneath this container."""
442
+ parent = self._resolve()
443
+ full_path = f"{parent.path}/{path}"
444
+ sub = Container(path=full_path, description=description)
445
+ sub = self._get_investigation().add_container(sub)
446
+ self._get_investigation().add_sub_container(self.key, sub.key)
447
+ return ContainerProxy(self._get_investigation(), sub.key)
448
+
449
+ def __enter__(self) -> ContainerProxy:
450
+ """Context manager entry returning self."""
451
+ return self
452
+
453
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
454
+ """Context manager exit (no-op)."""
455
+ return None
456
+
457
+ def update_metadata(self, *, description: str | None = None) -> ContainerProxy:
458
+ """Update mutable metadata on the container."""
459
+ if description is None:
460
+ return self
461
+ self._get_investigation().update_model_metadata("container", self.key, {"description": description})
462
+ return self
463
+
464
+
465
+ class ThreatIntelProxy(_ReadOnlyProxy[ThreatIntel]):
466
+ """Read-only proxy over a threat intel entry."""
467
+
468
+ def _resolve(self):
469
+ ti = self._get_investigation().get_threat_intel(self.key)
470
+ if ti is None:
471
+ raise ModelNotFoundError(f"Threat intel '{self.key}' no longer exists in this investigation.")
472
+ return ti
473
+
474
+ @property
475
+ def source(self) -> str:
476
+ return self._read_attr("source")
477
+
478
+ @property
479
+ def observable_key(self) -> str:
480
+ return self._read_attr("observable_key")
481
+
482
+ @property
483
+ def comment(self) -> str:
484
+ return self._read_attr("comment")
485
+
486
+ @property
487
+ def extra(self) -> dict[str, Any]:
488
+ return self._read_attr("extra")
489
+
490
+ @property
491
+ def score(self) -> Decimal:
492
+ return self._read_attr("score")
493
+
494
+ @property
495
+ def score_display(self) -> str:
496
+ return self._read_attr("score_display")
497
+
498
+ @property
499
+ def level(self) -> Level:
500
+ return self._read_attr("level")
501
+
502
+ @property
503
+ def taxonomies(self) -> list[Taxonomy]:
504
+ return self._read_attr("taxonomies")
505
+
506
+ def add_taxonomy(self, *, level: Level, name: str, value: str) -> ThreatIntelProxy:
507
+ """Add or replace a taxonomy by name."""
508
+ taxonomy = Taxonomy(level=level, name=name, value=value)
509
+ self._get_investigation().add_threat_intel_taxonomy(self.key, taxonomy)
510
+ return self
511
+
512
+ def remove_taxonomy(self, name: str) -> ThreatIntelProxy:
513
+ """Remove a taxonomy by name."""
514
+ self._get_investigation().remove_threat_intel_taxonomy(self.key, name)
515
+ return self
516
+
517
+ def update_metadata(
518
+ self,
519
+ *,
520
+ comment: str | None = None,
521
+ extra: dict[str, Any] | None = None,
522
+ level: Level | None = None,
523
+ merge_extra: bool = True,
524
+ ) -> ThreatIntelProxy:
525
+ """Update mutable metadata on the threat intel entry."""
526
+ updates: dict[str, Any] = {}
527
+ if comment is not None:
528
+ updates["comment"] = comment
529
+ if extra is not None:
530
+ updates["extra"] = extra
531
+ if level is not None:
532
+ updates["level"] = level
533
+
534
+ if not updates:
535
+ return self
536
+
537
+ dict_merge = {"extra": merge_extra} if extra is not None else None
538
+ self._get_investigation().update_model_metadata("threat_intel", self.key, updates, dict_merge=dict_merge)
539
+ return self
540
+
541
+
542
+ class EnrichmentProxy(_ReadOnlyProxy[Enrichment]):
543
+ """Read-only proxy over an enrichment."""
544
+
545
+ def _resolve(self):
546
+ enrichment = self._get_investigation().get_enrichment(self.key)
547
+ if enrichment is None:
548
+ raise ModelNotFoundError(f"Enrichment '{self.key}' no longer exists in this investigation.")
549
+ return enrichment
550
+
551
+ @property
552
+ def name(self) -> str:
553
+ return self._read_attr("name")
554
+
555
+ @property
556
+ def data(self) -> dict[str, Any]:
557
+ return self._read_attr("data")
558
+
559
+ @property
560
+ def context(self) -> str:
561
+ return self._read_attr("context")
562
+
563
+ def update_metadata(
564
+ self,
565
+ *,
566
+ context: str | None = None,
567
+ data: dict[str, Any] | None = None,
568
+ merge_data: bool = True,
569
+ ) -> EnrichmentProxy:
570
+ """Update mutable metadata on the enrichment."""
571
+ updates: dict[str, Any] = {}
572
+ if context is not None:
573
+ updates["context"] = context
574
+ if data is not None:
575
+ updates["data"] = data
576
+
577
+ if not updates:
578
+ return self
579
+
580
+ dict_merge = {"data": merge_data} if data is not None else None
581
+ self._get_investigation().update_model_metadata("enrichment", self.key, updates, dict_merge=dict_merge)
582
+ return self