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/__init__.py +24 -5
- cyvest/cli.py +63 -1
- cyvest/compare.py +310 -0
- cyvest/cyvest.py +253 -181
- cyvest/investigation.py +276 -243
- cyvest/io_rich.py +141 -54
- cyvest/io_schema.py +1 -1
- cyvest/io_serialization.py +90 -91
- cyvest/keys.py +61 -18
- cyvest/model.py +55 -43
- cyvest/model_schema.py +9 -9
- cyvest/proxies.py +48 -50
- cyvest/shared.py +19 -19
- cyvest/stats.py +11 -36
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/METADATA +105 -12
- cyvest-5.1.0.dist-info/RECORD +24 -0
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/WHEEL +1 -1
- cyvest-4.4.0.dist-info/RECORD +0 -23
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/entry_points.txt +0 -0
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
134
|
-
if
|
|
97
|
+
def _tag_proxy(self, tag: Tag | None) -> TagProxy | None:
|
|
98
|
+
if tag is None:
|
|
135
99
|
return None
|
|
136
|
-
return
|
|
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
|
|
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.
|
|
374
|
-
target_key = self.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
585
|
+
Get a check by key.
|
|
635
586
|
|
|
636
587
|
Args:
|
|
637
|
-
key: Check key
|
|
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.
|
|
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
|
-
#
|
|
642
|
+
# Tag methods
|
|
721
643
|
|
|
722
|
-
def
|
|
644
|
+
def tag_create(self, name: str, description: str = "") -> TagProxy:
|
|
723
645
|
"""
|
|
724
|
-
Create a new
|
|
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
|
-
|
|
728
|
-
description:
|
|
654
|
+
name: Tag name (use ":" as hierarchy delimiter)
|
|
655
|
+
description: Tag description
|
|
729
656
|
|
|
730
657
|
Returns:
|
|
731
|
-
The created
|
|
658
|
+
The created tag
|
|
732
659
|
"""
|
|
733
|
-
|
|
734
|
-
return self.
|
|
660
|
+
tag = Tag(name=name, description=description)
|
|
661
|
+
return self._tag_proxy(self._investigation.add_tag(tag))
|
|
735
662
|
|
|
736
|
-
def
|
|
663
|
+
def tag_get(self, *args, **kwargs) -> TagProxy | None:
|
|
737
664
|
"""
|
|
738
|
-
Get a
|
|
665
|
+
Get a tag by key or by name.
|
|
739
666
|
|
|
740
667
|
Args:
|
|
741
|
-
key:
|
|
742
|
-
|
|
668
|
+
key: Tag key (single argument, prefixed with tag:)
|
|
669
|
+
name: Tag name (single argument without prefix)
|
|
743
670
|
|
|
744
671
|
Returns:
|
|
745
|
-
|
|
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) == {"
|
|
754
|
-
|
|
680
|
+
elif not args and set(kwargs) == {"name"}:
|
|
681
|
+
name = kwargs["name"]
|
|
755
682
|
try:
|
|
756
|
-
key = keys.
|
|
683
|
+
key = keys.generate_tag_key(name)
|
|
757
684
|
except Exception as e:
|
|
758
|
-
raise ValueError(f"Failed to generate
|
|
685
|
+
raise ValueError(f"Failed to generate tag key for name='{name}': {e}") from e
|
|
759
686
|
else:
|
|
760
|
-
raise ValueError("
|
|
687
|
+
raise ValueError("tag_get() accepts either (key: str) or (name: str)")
|
|
761
688
|
elif len(args) == 1:
|
|
762
|
-
|
|
763
|
-
if isinstance(
|
|
764
|
-
key =
|
|
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.
|
|
694
|
+
key = keys.generate_tag_key(key_or_name)
|
|
768
695
|
except Exception as e:
|
|
769
|
-
raise ValueError(f"Failed to generate
|
|
696
|
+
raise ValueError(f"Failed to generate tag key for name='{key_or_name}': {e}") from e
|
|
770
697
|
else:
|
|
771
|
-
raise ValueError("
|
|
772
|
-
return self.
|
|
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
|
|
775
|
-
"""Get read-only proxies for all
|
|
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
|
|
705
|
+
def tag_add_check(self, tag_key: str, check_key: str) -> TagProxy:
|
|
781
706
|
"""
|
|
782
|
-
Add a check to a
|
|
707
|
+
Add a check to a tag.
|
|
783
708
|
|
|
784
709
|
Args:
|
|
785
|
-
|
|
710
|
+
tag_key: Key of the tag
|
|
786
711
|
check_key: Key of the check
|
|
787
712
|
|
|
788
713
|
Returns:
|
|
789
|
-
The
|
|
714
|
+
The tag
|
|
790
715
|
|
|
791
716
|
Raises:
|
|
792
|
-
KeyError: If the
|
|
717
|
+
KeyError: If the tag or check does not exist
|
|
793
718
|
"""
|
|
794
|
-
|
|
795
|
-
return self.
|
|
719
|
+
tag = self._investigation.add_check_to_tag(tag_key, check_key)
|
|
720
|
+
return self._tag_proxy(tag)
|
|
796
721
|
|
|
797
|
-
def
|
|
798
|
-
"""
|
|
799
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1091
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1311
|
+
return self.check_create(check_name, description, comment, extra, score, level)
|
|
1240
1312
|
|
|
1241
|
-
def
|
|
1313
|
+
def tag(self, name: str, description: str = "") -> TagProxy:
|
|
1242
1314
|
"""
|
|
1243
|
-
Create a
|
|
1315
|
+
Create a tag with fluent helper methods.
|
|
1244
1316
|
|
|
1245
1317
|
Args:
|
|
1246
|
-
|
|
1247
|
-
description:
|
|
1318
|
+
name: Tag name (use ":" as hierarchy delimiter)
|
|
1319
|
+
description: Tag description
|
|
1248
1320
|
|
|
1249
1321
|
Returns:
|
|
1250
|
-
|
|
1322
|
+
Tag proxy exposing mutation helpers for chaining
|
|
1251
1323
|
"""
|
|
1252
|
-
return self.
|
|
1324
|
+
return self.tag_create(name, description)
|
|
1253
1325
|
|
|
1254
1326
|
def root(self) -> ObservableProxy:
|
|
1255
1327
|
"""
|