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/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