cyvest 4.4.0__py3-none-any.whl → 5.1.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/cyvest.py CHANGED
@@ -4,14 +4,14 @@ Cyvest facade - high-level API for building cybersecurity investigations.
4
4
  Provides a simplified interface for creating and managing investigation objects,
5
5
  handling score propagation, and generating reports.
6
6
 
7
- Includes JSON/Markdown export (io_save_json, io_save_markdown), import (io_load_json),
8
- and investigation export (io_to_invest, io_to_markdown) methods.
7
+ Includes JSON/Markdown export (io_save_json, io_save_markdown), import (io_load_json, io_load_dict),
8
+ and investigation export (io_to_invest, io_to_dict, io_to_markdown) methods.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
13
  import threading
14
- from collections.abc import Iterable
14
+ from collections.abc import Callable, Iterable
15
15
  from decimal import Decimal
16
16
  from pathlib import Path
17
17
  from typing import TYPE_CHECKING, Any, Final, Literal, overload
@@ -19,20 +19,23 @@ from typing import TYPE_CHECKING, Any, Final, Literal, overload
19
19
  from logurich import logger
20
20
 
21
21
  from cyvest import keys
22
+ from cyvest.compare import compare_investigations
22
23
  from cyvest.investigation import Investigation, InvestigationWhitelist
23
- from cyvest.io_rich import display_statistics, display_summary
24
+ from cyvest.io_rich import display_diff, display_statistics, display_summary
24
25
  from cyvest.io_serialization import (
25
26
  generate_markdown_report,
27
+ load_investigation_dict,
26
28
  load_investigation_json,
27
29
  save_investigation_json,
28
30
  save_investigation_markdown,
29
31
  serialize_investigation,
30
32
  )
33
+ from cyvest.io_visualization import generate_network_graph
31
34
  from cyvest.levels import Level
32
- from cyvest.model import Check, Container, Enrichment, Observable, Taxonomy, ThreatIntel
35
+ from cyvest.model import Check, Enrichment, Observable, Tag, Taxonomy, ThreatIntel
33
36
  from cyvest.model_enums import ObservableType, PropagationMode, RelationshipDirection, RelationshipType
34
37
  from cyvest.model_schema import InvestigationSchema, StatisticsSchema
35
- from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
38
+ from cyvest.proxies import CheckProxy, EnrichmentProxy, ObservableProxy, TagProxy, ThreatIntelProxy
36
39
  from cyvest.score import ScoreMode
37
40
 
38
41
  if TYPE_CHECKING:
@@ -44,7 +47,7 @@ class Cyvest:
44
47
  High-level facade for building and managing cybersecurity investigations.
45
48
 
46
49
  Provides methods for creating observables, checks, threat intel, enrichments,
47
- and containers, with automatic score propagation and statistics tracking.
50
+ and tags, with automatic score propagation and statistics tracking.
48
51
  """
49
52
 
50
53
  OBS: Final[type[ObservableType]] = ObservableType
@@ -79,46 +82,7 @@ class Cyvest:
79
82
  investigation_id=investigation_id,
80
83
  )
81
84
 
82
- @staticmethod
83
- def io_load_json(filepath: str | Path) -> Cyvest:
84
- """
85
- Load an investigation from a JSON file.
86
-
87
- Args:
88
- filepath: Path to the JSON file (relative or absolute)
89
-
90
- Returns:
91
- Reconstructed Cyvest investigation
92
-
93
- Raises:
94
- FileNotFoundError: If the file does not exist
95
- json.JSONDecodeError: If the file contains invalid JSON
96
- Exception: For other file-related errors
97
-
98
- Example:
99
- >>> cv = Cyvest.io_load_json("investigation.json")
100
- >>> cv = Cyvest.io_load_json("/absolute/path/to/investigation.json")
101
- """
102
- return load_investigation_json(filepath)
103
-
104
- def shared_context(
105
- self,
106
- *,
107
- lock: threading.RLock | None = None,
108
- max_async_workers: int | None = None,
109
- ) -> SharedInvestigationContext:
110
- """
111
- Create a SharedInvestigationContext tied to this Cyvest instance.
112
-
113
- Args:
114
- lock: Optional shared lock for advanced synchronization scenarios.
115
- max_async_workers: Optional limit for concurrent async reconciliation workers.
116
- """
117
- from cyvest.shared import SharedInvestigationContext
118
-
119
- return SharedInvestigationContext(self, lock=lock, max_async_workers=max_async_workers)
120
-
121
- # Internal helpers -------------------------------------------------
85
+ # Internal helpers
122
86
 
123
87
  def _observable_proxy(self, observable: Observable | None) -> ObservableProxy | None:
124
88
  if observable is None:
@@ -130,10 +94,10 @@ class Cyvest:
130
94
  return None
131
95
  return CheckProxy(self._investigation, check.key)
132
96
 
133
- def _container_proxy(self, container: Container | None) -> ContainerProxy | None:
134
- if container is None:
97
+ def _tag_proxy(self, tag: Tag | None) -> TagProxy | None:
98
+ if tag is None:
135
99
  return None
136
- return ContainerProxy(self._investigation, container.key)
100
+ return TagProxy(self._investigation, tag.key)
137
101
 
138
102
  def _threat_intel_proxy(self, ti: ThreatIntel | None) -> ThreatIntelProxy | None:
139
103
  if ti is None:
@@ -146,7 +110,7 @@ class Cyvest:
146
110
  return EnrichmentProxy(self._investigation, enrichment.key)
147
111
 
148
112
  @staticmethod
149
- def _resolve_key(value: Observable | ObservableProxy | str) -> str:
113
+ def _resolve_observable_key(value: Observable | ObservableProxy | str) -> str:
150
114
  if isinstance(value, str):
151
115
  return value
152
116
  if isinstance(value, (Observable, ObservableProxy)):
@@ -370,8 +334,8 @@ class Cyvest:
370
334
  Raises:
371
335
  KeyError: If the source or target observable does not exist
372
336
  """
373
- source_key = self._resolve_key(source)
374
- target_key = self._resolve_key(target)
337
+ source_key = self._resolve_observable_key(source)
338
+ target_key = self._resolve_observable_key(target)
375
339
  result = self._investigation.add_relationship(source_key, target_key, relationship_type, direction)
376
340
  return self._observable_proxy(result)
377
341
 
@@ -403,7 +367,7 @@ class Cyvest:
403
367
  Raises:
404
368
  KeyError: If the observable does not exist
405
369
  """
406
- observable_key = self._resolve_key(observable)
370
+ observable_key = self._resolve_observable_key(observable)
407
371
  observable = self._require_observable(observable_key)
408
372
 
409
373
  ti_kwargs: dict[str, Any] = {
@@ -441,7 +405,7 @@ class Cyvest:
441
405
  if not isinstance(threat_intel, ThreatIntel):
442
406
  raise TypeError("Threat intel draft must be a ThreatIntel instance.")
443
407
 
444
- observable_key = self._resolve_key(observable)
408
+ observable_key = self._resolve_observable_key(observable)
445
409
  model_observable = self._require_observable(observable_key)
446
410
 
447
411
  if threat_intel.observable_key and threat_intel.observable_key != observable_key:
@@ -474,7 +438,7 @@ class Cyvest:
474
438
  Raises:
475
439
  KeyError: If the observable does not exist
476
440
  """
477
- observable_key = self._resolve_key(observable)
441
+ observable_key = self._resolve_observable_key(observable)
478
442
  model_observable = self._require_observable(observable_key)
479
443
  self._investigation.apply_level_change(
480
444
  model_observable,
@@ -581,8 +545,7 @@ class Cyvest:
581
545
 
582
546
  def check_create(
583
547
  self,
584
- check_id: str,
585
- scope: str,
548
+ check_name: str,
586
549
  description: str,
587
550
  comment: str = "",
588
551
  extra: dict[str, Any] | None = None,
@@ -593,8 +556,7 @@ class Cyvest:
593
556
  Create a new check.
594
557
 
595
558
  Args:
596
- check_id: Check identifier
597
- scope: Check scope
559
+ check_name: Check name
598
560
  description: Check description
599
561
  comment: Optional comment
600
562
  extra: Optional extra data
@@ -605,8 +567,7 @@ class Cyvest:
605
567
  The created check
606
568
  """
607
569
  check_kwargs: dict[str, Any] = {
608
- "check_id": check_id,
609
- "scope": scope,
570
+ "check_name": check_name,
610
571
  "description": description,
611
572
  "comment": comment,
612
573
  "extra": extra or {},
@@ -619,55 +580,16 @@ class Cyvest:
619
580
  check = Check(**check_kwargs)
620
581
  return self._check_proxy(self._investigation.add_check(check))
621
582
 
622
- @overload
623
583
  def check_get(self, key: str) -> CheckProxy | None:
624
- """Get a check by full key string."""
625
- ...
626
-
627
- @overload
628
- def check_get(self, check_id: str, scope: str) -> CheckProxy | None:
629
- """Get a check by ID and scope."""
630
- ...
631
-
632
- def check_get(self, *args, **kwargs) -> CheckProxy | None:
633
584
  """
634
- Get a check by key or by check ID and scope.
585
+ Get a check by key.
635
586
 
636
587
  Args:
637
- key: Check key (single argument)
638
- check_id: Check identifier (when using two arguments)
639
- scope: Check scope (when using two arguments)
588
+ key: Check key
640
589
 
641
590
  Returns:
642
591
  Check if found, None otherwise
643
-
644
- Raises:
645
- ValueError: If arguments are invalid or key generation fails
646
592
  """
647
- if kwargs:
648
- if not args and set(kwargs) == {"key"}:
649
- key = kwargs["key"]
650
- elif not args and set(kwargs) == {"check_id", "scope"}:
651
- check_id = kwargs["check_id"]
652
- scope = kwargs["scope"]
653
- try:
654
- key = keys.generate_check_key(check_id, scope)
655
- except Exception as e:
656
- raise ValueError(
657
- f"Failed to generate check key for check_id='{check_id}', scope='{scope}': {e}"
658
- ) from e
659
- else:
660
- raise ValueError("check_get() accepts either (key: str) or (check_id: str, scope: str)")
661
- elif len(args) == 1:
662
- key = args[0]
663
- elif len(args) == 2:
664
- check_id, scope = args
665
- try:
666
- key = keys.generate_check_key(check_id, scope)
667
- except Exception as e:
668
- raise ValueError(f"Failed to generate check key for check_id='{check_id}', scope='{scope}': {e}") from e
669
- else:
670
- raise ValueError("check_get() accepts either (key: str) or (check_id: str, scope: str)")
671
593
  return self._check_proxy(self._investigation.get_check(key))
672
594
 
673
595
  def check_get_all(self) -> dict[str, CheckProxy]:
@@ -694,7 +616,7 @@ class Cyvest:
694
616
  Raises:
695
617
  KeyError: If the check or observable does not exist
696
618
  """
697
- observable_key = self._resolve_key(observable)
619
+ observable_key = self._resolve_observable_key(observable)
698
620
  result = self._investigation.link_check_observable(check_key, observable_key, propagation_mode=propagation_mode)
699
621
  return self._check_proxy(result)
700
622
 
@@ -717,32 +639,37 @@ class Cyvest:
717
639
  self._investigation.apply_score_change(check, Decimal(str(score)), reason=reason)
718
640
  return self._check_proxy(check)
719
641
 
720
- # Container methods
642
+ # Tag methods
721
643
 
722
- def container_create(self, path: str, description: str = "") -> ContainerProxy:
644
+ def tag_create(self, name: str, description: str = "") -> TagProxy:
723
645
  """
724
- Create a new container.
646
+ Create a new tag, automatically creating ancestor tags.
647
+
648
+ When creating a tag with a hierarchical name (using ":" delimiter),
649
+ ancestor tags are automatically created if they don't exist.
650
+ For example, creating "header:auth:dkim" will auto-create
651
+ "header" and "header:auth" tags.
725
652
 
726
653
  Args:
727
- path: Container path
728
- description: Container description
654
+ name: Tag name (use ":" as hierarchy delimiter)
655
+ description: Tag description
729
656
 
730
657
  Returns:
731
- The created container
658
+ The created tag
732
659
  """
733
- container = Container(path=path, description=description)
734
- return self._container_proxy(self._investigation.add_container(container))
660
+ tag = Tag(name=name, description=description)
661
+ return self._tag_proxy(self._investigation.add_tag(tag))
735
662
 
736
- def container_get(self, *args, **kwargs) -> ContainerProxy | None:
663
+ def tag_get(self, *args, **kwargs) -> TagProxy | None:
737
664
  """
738
- Get a container by key or by path.
665
+ Get a tag by key or by name.
739
666
 
740
667
  Args:
741
- key: Container key (single argument, prefixed with ctr:)
742
- path: Container path (single argument without prefix)
668
+ key: Tag key (single argument, prefixed with tag:)
669
+ name: Tag name (single argument without prefix)
743
670
 
744
671
  Returns:
745
- Container if found, None otherwise
672
+ Tag if found, None otherwise
746
673
 
747
674
  Raises:
748
675
  ValueError: If arguments are invalid or key generation fails
@@ -750,66 +677,62 @@ class Cyvest:
750
677
  if kwargs:
751
678
  if not args and set(kwargs) == {"key"}:
752
679
  key = kwargs["key"]
753
- elif not args and set(kwargs) == {"path"}:
754
- path = kwargs["path"]
680
+ elif not args and set(kwargs) == {"name"}:
681
+ name = kwargs["name"]
755
682
  try:
756
- key = keys.generate_container_key(path)
683
+ key = keys.generate_tag_key(name)
757
684
  except Exception as e:
758
- raise ValueError(f"Failed to generate container key for path='{path}': {e}") from e
685
+ raise ValueError(f"Failed to generate tag key for name='{name}': {e}") from e
759
686
  else:
760
- raise ValueError("container_get() accepts either (key: str) or (path: str)")
687
+ raise ValueError("tag_get() accepts either (key: str) or (name: str)")
761
688
  elif len(args) == 1:
762
- key_or_path = args[0]
763
- if isinstance(key_or_path, str) and key_or_path.startswith("ctr:"):
764
- key = key_or_path
689
+ key_or_name = args[0]
690
+ if isinstance(key_or_name, str) and key_or_name.startswith("tag:"):
691
+ key = key_or_name
765
692
  else:
766
693
  try:
767
- key = keys.generate_container_key(key_or_path)
694
+ key = keys.generate_tag_key(key_or_name)
768
695
  except Exception as e:
769
- raise ValueError(f"Failed to generate container key for path='{key_or_path}': {e}") from e
696
+ raise ValueError(f"Failed to generate tag key for name='{key_or_name}': {e}") from e
770
697
  else:
771
- raise ValueError("container_get() accepts either (key: str) or (path: str)")
772
- return self._container_proxy(self._investigation.get_container(key))
698
+ raise ValueError("tag_get() accepts either (key: str) or (name: str)")
699
+ return self._tag_proxy(self._investigation.get_tag(key))
773
700
 
774
- def container_get_all(self) -> dict[str, ContainerProxy]:
775
- """Get read-only proxies for all containers."""
776
- return {
777
- key: ContainerProxy(self._investigation, key) for key in self._investigation.get_all_containers().keys()
778
- }
701
+ def tag_get_all(self) -> dict[str, TagProxy]:
702
+ """Get read-only proxies for all tags."""
703
+ return {key: TagProxy(self._investigation, key) for key in self._investigation.get_all_tags().keys()}
779
704
 
780
- def container_add_check(self, container_key: str, check_key: str) -> ContainerProxy:
705
+ def tag_add_check(self, tag_key: str, check_key: str) -> TagProxy:
781
706
  """
782
- Add a check to a container.
707
+ Add a check to a tag.
783
708
 
784
709
  Args:
785
- container_key: Key of the container
710
+ tag_key: Key of the tag
786
711
  check_key: Key of the check
787
712
 
788
713
  Returns:
789
- The container
714
+ The tag
790
715
 
791
716
  Raises:
792
- KeyError: If the container or check does not exist
717
+ KeyError: If the tag or check does not exist
793
718
  """
794
- container = self._investigation.add_check_to_container(container_key, check_key)
795
- return self._container_proxy(container)
719
+ tag = self._investigation.add_check_to_tag(tag_key, check_key)
720
+ return self._tag_proxy(tag)
796
721
 
797
- def container_add_sub_container(self, parent_key: str, child_key: str) -> ContainerProxy:
798
- """
799
- Add a sub-container to a container.
722
+ def tag_get_children(self, tag_name: str) -> list[TagProxy]:
723
+ """Get direct child tags of a tag."""
724
+ tags = self._investigation.get_tag_children(tag_name)
725
+ return [TagProxy(self._investigation, t.key) for t in tags]
800
726
 
801
- Args:
802
- parent_key: Key of the parent container
803
- child_key: Key of the child container
727
+ def tag_get_descendants(self, tag_name: str) -> list[TagProxy]:
728
+ """Get all descendant tags of a tag."""
729
+ tags = self._investigation.get_tag_descendants(tag_name)
730
+ return [TagProxy(self._investigation, t.key) for t in tags]
804
731
 
805
- Returns:
806
- The parent container
807
-
808
- Raises:
809
- KeyError: If the parent or child container does not exist
810
- """
811
- parent = self._investigation.add_sub_container(parent_key, child_key)
812
- return self._container_proxy(parent)
732
+ def tag_get_ancestors(self, tag_name: str) -> list[TagProxy]:
733
+ """Get all ancestor tags of a tag."""
734
+ tags = self._investigation.get_tag_ancestors(tag_name)
735
+ return [TagProxy(self._investigation, t.key) for t in tags]
813
736
 
814
737
  # Enrichment methods
815
738
 
@@ -971,7 +894,7 @@ class Cyvest:
971
894
  def io_save_markdown(
972
895
  self,
973
896
  filepath: str | Path,
974
- include_containers: bool = False,
897
+ include_tags: bool = False,
975
898
  include_enrichments: bool = False,
976
899
  include_observables: bool = True,
977
900
  ) -> str:
@@ -982,7 +905,7 @@ class Cyvest:
982
905
 
983
906
  Args:
984
907
  filepath: Path to save the Markdown file (relative or absolute)
985
- include_containers: Include containers section in the report (default: False)
908
+ include_tags: Include tags section in the report (default: False)
986
909
  include_enrichments: Include enrichments section in the report (default: False)
987
910
  include_observables: Include observables section in the report (default: True)
988
911
 
@@ -999,13 +922,13 @@ class Cyvest:
999
922
  >>> print(path) # /absolute/path/to/report.md
1000
923
  """
1001
924
  save_investigation_markdown(
1002
- self._investigation, filepath, include_containers, include_enrichments, include_observables
925
+ self._investigation, filepath, include_tags, include_enrichments, include_observables
1003
926
  )
1004
927
  return str(Path(filepath).resolve())
1005
928
 
1006
929
  def io_to_markdown(
1007
930
  self,
1008
- include_containers: bool = False,
931
+ include_tags: bool = False,
1009
932
  include_enrichments: bool = False,
1010
933
  include_observables: bool = True,
1011
934
  ) -> str:
@@ -1013,7 +936,7 @@ class Cyvest:
1013
936
  Generate a Markdown report of the investigation.
1014
937
 
1015
938
  Args:
1016
- include_containers: Include containers section in the report (default: False)
939
+ include_tags: Include tags section in the report (default: False)
1017
940
  include_enrichments: Include enrichments section in the report (default: False)
1018
941
  include_observables: Include observables section in the report (default: True)
1019
942
 
@@ -1027,9 +950,7 @@ class Cyvest:
1027
950
  # Cybersecurity Investigation Report
1028
951
  ...
1029
952
  """
1030
- return generate_markdown_report(
1031
- self._investigation, include_containers, include_enrichments, include_observables
1032
- )
953
+ return generate_markdown_report(self._investigation, include_tags, include_enrichments, include_observables)
1033
954
 
1034
955
  def io_to_invest(self, *, include_audit_log: bool = True) -> InvestigationSchema:
1035
956
  """
@@ -1046,14 +967,96 @@ class Cyvest:
1046
967
  >>> cv = Cyvest()
1047
968
  >>> schema = cv.io_to_invest()
1048
969
  >>> print(schema.score, schema.level)
1049
- >>> dict_data = schema.model_dump(by_alias=True)
970
+ >>> dict_data = schema.model_dump() # defaults to by_alias=True
1050
971
  >>> # For compact, deterministic output:
1051
972
  >>> schema = cv.io_to_invest(include_audit_log=False)
1052
973
  >>> assert schema.audit_log is None
1053
974
  """
1054
975
  return serialize_investigation(self._investigation, include_audit_log=include_audit_log)
1055
976
 
1056
- # Merge methods
977
+ def io_to_dict(self, *, include_audit_log: bool = True) -> dict[str, Any]:
978
+ """
979
+ Convert the investigation to a Python dictionary.
980
+
981
+ Args:
982
+ include_audit_log: Include audit log in output (default: True).
983
+ When False, audit_log is set to None for compact, deterministic output.
984
+
985
+ Returns:
986
+ Dictionary representation of the investigation
987
+
988
+ Examples:
989
+ >>> cv = Cyvest()
990
+ >>> data = cv.io_to_dict()
991
+ >>> print(data["score"], data["level"])
992
+ >>> # For compact, deterministic output:
993
+ >>> data = cv.io_to_dict(include_audit_log=False)
994
+ >>> assert data["audit_log"] is None
995
+ """
996
+ return self.io_to_invest(include_audit_log=include_audit_log).model_dump(by_alias=True)
997
+
998
+ @staticmethod
999
+ def io_load_json(filepath: str | Path) -> Cyvest:
1000
+ """
1001
+ Load an investigation from a JSON file.
1002
+
1003
+ Args:
1004
+ filepath: Path to the JSON file (relative or absolute)
1005
+
1006
+ Returns:
1007
+ Reconstructed Cyvest investigation
1008
+
1009
+ Raises:
1010
+ FileNotFoundError: If the file does not exist
1011
+ json.JSONDecodeError: If the file contains invalid JSON
1012
+ Exception: For other file-related errors
1013
+
1014
+ Example:
1015
+ >>> cv = Cyvest.io_load_json("investigation.json")
1016
+ >>> cv = Cyvest.io_load_json("/absolute/path/to/investigation.json")
1017
+ """
1018
+ return load_investigation_json(filepath)
1019
+
1020
+ @staticmethod
1021
+ def io_load_dict(data: dict[str, Any]) -> Cyvest:
1022
+ """
1023
+ Load an investigation from a dictionary (parsed JSON).
1024
+
1025
+ Args:
1026
+ data: Dictionary containing the serialized investigation data
1027
+
1028
+ Returns:
1029
+ Reconstructed Cyvest investigation
1030
+
1031
+ Raises:
1032
+ ValueError: If required fields are missing or invalid
1033
+
1034
+ Example:
1035
+ >>> import json
1036
+ >>> with open("investigation.json") as f:
1037
+ ... data = json.load(f)
1038
+ >>> cv = Cyvest.io_load_dict(data)
1039
+ """
1040
+ return load_investigation_dict(data)
1041
+
1042
+ # Shared context, investigation merging, finalization, comparison
1043
+
1044
+ def shared_context(
1045
+ self,
1046
+ *,
1047
+ lock: threading.RLock | None = None,
1048
+ max_async_workers: int | None = None,
1049
+ ) -> SharedInvestigationContext:
1050
+ """
1051
+ Create a SharedInvestigationContext tied to this Cyvest instance.
1052
+
1053
+ Args:
1054
+ lock: Optional shared lock for advanced synchronization scenarios.
1055
+ max_async_workers: Optional limit for concurrent async reconciliation workers.
1056
+ """
1057
+ from cyvest.shared import SharedInvestigationContext
1058
+
1059
+ return SharedInvestigationContext(self, lock=lock, max_async_workers=max_async_workers)
1057
1060
 
1058
1061
  def merge_investigation(self, other: Cyvest) -> None:
1059
1062
  """
@@ -1073,22 +1076,95 @@ class Cyvest:
1073
1076
  """
1074
1077
  self._investigation.finalize_relationships()
1075
1078
 
1079
+ def compare(
1080
+ self,
1081
+ expected: Cyvest | None = None,
1082
+ result_expected: list | None = None,
1083
+ ) -> list:
1084
+ """
1085
+ Compare this investigation against expected results.
1086
+
1087
+ Args:
1088
+ expected: The reference investigation (expected results), optional
1089
+ result_expected: List of ExpectedResult tolerance rules for specific checks
1090
+
1091
+ Returns:
1092
+ List of DiffItem for all differences found
1093
+ """
1094
+ return compare_investigations(actual=self, expected=expected, result_expected=result_expected)
1095
+
1096
+ # Display helpers
1097
+
1076
1098
  def display_summary(
1077
1099
  self,
1078
1100
  show_graph: bool = True,
1079
1101
  exclude_levels: Level | Iterable[Level] = Level.NONE,
1080
1102
  show_audit_log: bool = False,
1103
+ rich_print: Callable[[Any], None] | None = None,
1081
1104
  ) -> None:
1105
+ """
1106
+ Display a comprehensive summary of the investigation using Rich.
1107
+
1108
+ Args:
1109
+ show_graph: Whether to display the observable graph
1110
+ exclude_levels: Level(s) to omit from the report (default: Level.NONE)
1111
+ show_audit_log: Whether to display the investigation audit log
1112
+ rich_print: Optional callable that takes a renderable and returns None
1113
+ """
1114
+ if rich_print is None:
1115
+
1116
+ def rich_print(renderables: Any) -> None:
1117
+ logger.rich("INFO", renderables)
1118
+
1082
1119
  display_summary(
1083
1120
  self,
1084
- lambda renderables: logger.rich("INFO", renderables),
1121
+ rich_print,
1085
1122
  show_graph=show_graph,
1086
1123
  exclude_levels=exclude_levels,
1087
1124
  show_audit_log=show_audit_log,
1088
1125
  )
1089
1126
 
1090
- def display_statistics(self) -> None:
1091
- display_statistics(self, lambda renderables: logger.rich("INFO", renderables))
1127
+ def display_statistics(
1128
+ self,
1129
+ rich_print: Callable[[Any], None] | None = None,
1130
+ ) -> None:
1131
+ """
1132
+ Display investigation statistics using Rich.
1133
+
1134
+ Args:
1135
+ rich_print: Optional callable that takes a renderable and returns None.
1136
+ If not provided, uses the default logger.
1137
+ """
1138
+ if rich_print is None:
1139
+
1140
+ def rich_print(renderables: Any) -> None:
1141
+ logger.rich("INFO", renderables)
1142
+
1143
+ display_statistics(self, rich_print)
1144
+
1145
+ def display_diff(
1146
+ self,
1147
+ expected: Cyvest | None = None,
1148
+ result_expected: list | None = None,
1149
+ title: str = "Diff",
1150
+ rich_print: Callable[[Any], None] | None = None,
1151
+ ) -> None:
1152
+ """
1153
+ Compare and display diff against expected results.
1154
+
1155
+ Args:
1156
+ expected: The reference investigation (expected results), optional
1157
+ result_expected: List of ExpectedResult tolerance rules for specific checks
1158
+ title: Title for the diff table
1159
+ rich_print: Optional callable that takes a renderable and returns None
1160
+ """
1161
+ if rich_print is None:
1162
+
1163
+ def rich_print(renderables):
1164
+ return logger.rich("INFO", renderables, width=150)
1165
+
1166
+ diffs = compare_investigations(actual=self, expected=expected, result_expected=result_expected)
1167
+ display_diff(diffs, rich_print, title=title)
1092
1168
 
1093
1169
  def display_network(
1094
1170
  self,
@@ -1127,8 +1203,6 @@ class Cyvest:
1127
1203
  >>> cv.display_network()
1128
1204
  '/tmp/cyvest_12345/cyvest_network.html'
1129
1205
  """
1130
- from cyvest.io_visualization import generate_network_graph
1131
-
1132
1206
  return generate_network_graph(
1133
1207
  self,
1134
1208
  output_dir=output_dir,
@@ -1213,8 +1287,7 @@ class Cyvest:
1213
1287
 
1214
1288
  def check(
1215
1289
  self,
1216
- check_id: str,
1217
- scope: str,
1290
+ check_name: str,
1218
1291
  description: str,
1219
1292
  comment: str = "",
1220
1293
  extra: dict[str, Any] | None = None,
@@ -1225,8 +1298,7 @@ class Cyvest:
1225
1298
  Create a check with fluent helper methods.
1226
1299
 
1227
1300
  Args:
1228
- check_id: Check identifier
1229
- scope: Check scope
1301
+ check_name: Check name
1230
1302
  description: Check description
1231
1303
  comment: Optional comment
1232
1304
  extra: Optional extra data
@@ -1236,20 +1308,20 @@ class Cyvest:
1236
1308
  Returns:
1237
1309
  Check proxy exposing mutation helpers for chaining
1238
1310
  """
1239
- return self.check_create(check_id, scope, description, comment, extra, score, level)
1311
+ return self.check_create(check_name, description, comment, extra, score, level)
1240
1312
 
1241
- def container(self, path: str, description: str = "") -> ContainerProxy:
1313
+ def tag(self, name: str, description: str = "") -> TagProxy:
1242
1314
  """
1243
- Create a container with fluent helper methods.
1315
+ Create a tag with fluent helper methods.
1244
1316
 
1245
1317
  Args:
1246
- path: Container path
1247
- description: Container description
1318
+ name: Tag name (use ":" as hierarchy delimiter)
1319
+ description: Tag description
1248
1320
 
1249
1321
  Returns:
1250
- Container proxy exposing mutation helpers for chaining
1322
+ Tag proxy exposing mutation helpers for chaining
1251
1323
  """
1252
- return self.container_create(path, description)
1324
+ return self.tag_create(name, description)
1253
1325
 
1254
1326
  def root(self) -> ObservableProxy:
1255
1327
  """