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/investigation.py
CHANGED
|
@@ -14,18 +14,19 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
14
14
|
|
|
15
15
|
from logurich import logger
|
|
16
16
|
|
|
17
|
+
from cyvest import keys
|
|
17
18
|
from cyvest.level_score_rules import recalculate_level_for_score
|
|
18
19
|
from cyvest.levels import Level, normalize_level
|
|
19
20
|
from cyvest.model import (
|
|
20
21
|
AuditEvent,
|
|
21
22
|
Check,
|
|
22
|
-
Container,
|
|
23
23
|
Enrichment,
|
|
24
24
|
InvestigationWhitelist,
|
|
25
25
|
Observable,
|
|
26
26
|
ObservableLink,
|
|
27
27
|
ObservableType,
|
|
28
28
|
Relationship,
|
|
29
|
+
Tag,
|
|
29
30
|
Taxonomy,
|
|
30
31
|
ThreatIntel,
|
|
31
32
|
)
|
|
@@ -63,7 +64,7 @@ class Investigation:
|
|
|
63
64
|
"fields": {"context", "data"},
|
|
64
65
|
"dict_fields": {"data"},
|
|
65
66
|
},
|
|
66
|
-
"
|
|
67
|
+
"tag": {
|
|
67
68
|
"fields": {"description"},
|
|
68
69
|
"dict_fields": set(),
|
|
69
70
|
},
|
|
@@ -104,7 +105,7 @@ class Investigation:
|
|
|
104
105
|
self._checks: dict[str, Check] = {}
|
|
105
106
|
self._threat_intels: dict[str, ThreatIntel] = {}
|
|
106
107
|
self._enrichments: dict[str, Enrichment] = {}
|
|
107
|
-
self.
|
|
108
|
+
self._tags: dict[str, Tag] = {}
|
|
108
109
|
|
|
109
110
|
# Internal components
|
|
110
111
|
normalized_score_mode_obs = ScoreMode.normalize(score_mode_obs)
|
|
@@ -172,12 +173,12 @@ class Investigation:
|
|
|
172
173
|
# Fallback if no INVESTIGATION_STARTED event (shouldn't happen)
|
|
173
174
|
return datetime.now(timezone.utc)
|
|
174
175
|
|
|
175
|
-
def
|
|
176
|
+
def _link_threat_intel_to_observable(self, observable: Observable, ti: ThreatIntel) -> None:
|
|
176
177
|
if any(existing.key == ti.key for existing in observable.threat_intels):
|
|
177
178
|
return
|
|
178
179
|
observable.threat_intels.append(ti)
|
|
179
180
|
|
|
180
|
-
def
|
|
181
|
+
def _create_relationship(
|
|
181
182
|
self,
|
|
182
183
|
source_obs: Observable,
|
|
183
184
|
target_key: str,
|
|
@@ -190,7 +191,7 @@ class Investigation:
|
|
|
190
191
|
if rel_tuple not in existing_rels:
|
|
191
192
|
source_obs.relationships.append(rel)
|
|
192
193
|
|
|
193
|
-
def
|
|
194
|
+
def _link_check_to_observable(self, check: Check, link: ObservableLink) -> bool:
|
|
194
195
|
existing: dict[tuple[str, PropagationMode], int] = {}
|
|
195
196
|
for idx, existing_link in enumerate(check.observable_links):
|
|
196
197
|
existing[(existing_link.observable_key, existing_link.propagation_mode)] = idx
|
|
@@ -201,15 +202,12 @@ class Investigation:
|
|
|
201
202
|
check.observable_links.append(link)
|
|
202
203
|
return True
|
|
203
204
|
|
|
204
|
-
def
|
|
205
|
-
if any(existing.key == check.key for existing in
|
|
205
|
+
def _link_check_to_tag(self, tag: Tag, check: Check) -> None:
|
|
206
|
+
if any(existing.key == check.key for existing in tag.checks):
|
|
206
207
|
return
|
|
207
|
-
|
|
208
|
+
tag.checks.append(check)
|
|
208
209
|
|
|
209
|
-
def
|
|
210
|
-
parent.sub_containers[child.key] = child
|
|
211
|
-
|
|
212
|
-
def _infer_object_type(self, obj: Any) -> str | None:
|
|
210
|
+
def _get_object_type(self, obj: Any) -> str | None:
|
|
213
211
|
if isinstance(obj, Observable):
|
|
214
212
|
return "observable"
|
|
215
213
|
if isinstance(obj, Check):
|
|
@@ -218,10 +216,28 @@ class Investigation:
|
|
|
218
216
|
return "threat_intel"
|
|
219
217
|
if isinstance(obj, Enrichment):
|
|
220
218
|
return "enrichment"
|
|
221
|
-
if isinstance(obj,
|
|
222
|
-
return "
|
|
219
|
+
if isinstance(obj, Tag):
|
|
220
|
+
return "tag"
|
|
223
221
|
return None
|
|
224
222
|
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _normalize_taxonomies(value: Any) -> list[Taxonomy]:
|
|
225
|
+
if value is None:
|
|
226
|
+
return []
|
|
227
|
+
if not isinstance(value, list):
|
|
228
|
+
raise TypeError("taxonomies must be a list of taxonomy objects.")
|
|
229
|
+
taxonomies = [Taxonomy.model_validate(item) for item in value]
|
|
230
|
+
seen: set[str] = set()
|
|
231
|
+
duplicates: set[str] = set()
|
|
232
|
+
for taxonomy in taxonomies:
|
|
233
|
+
if taxonomy.name in seen:
|
|
234
|
+
duplicates.add(taxonomy.name)
|
|
235
|
+
seen.add(taxonomy.name)
|
|
236
|
+
if duplicates:
|
|
237
|
+
dupes = ", ".join(sorted(duplicates))
|
|
238
|
+
raise ValueError(f"Duplicate taxonomy name(s): {dupes}")
|
|
239
|
+
return taxonomies
|
|
240
|
+
|
|
225
241
|
def apply_score_change(
|
|
226
242
|
self,
|
|
227
243
|
obj: Any,
|
|
@@ -260,7 +276,7 @@ class Investigation:
|
|
|
260
276
|
|
|
261
277
|
self._record_event(
|
|
262
278
|
event_type=event_type,
|
|
263
|
-
object_type=self.
|
|
279
|
+
object_type=self._get_object_type(obj),
|
|
264
280
|
object_key=getattr(obj, "key", None),
|
|
265
281
|
reason=reason,
|
|
266
282
|
details=details,
|
|
@@ -284,7 +300,7 @@ class Investigation:
|
|
|
284
300
|
obj.level = new_level
|
|
285
301
|
self._record_event(
|
|
286
302
|
event_type=event_type,
|
|
287
|
-
object_type=self.
|
|
303
|
+
object_type=self._get_object_type(obj),
|
|
288
304
|
object_key=getattr(obj, "key", None),
|
|
289
305
|
reason=reason,
|
|
290
306
|
details={
|
|
@@ -295,16 +311,16 @@ class Investigation:
|
|
|
295
311
|
)
|
|
296
312
|
return True
|
|
297
313
|
|
|
298
|
-
def
|
|
314
|
+
def _update_observable_check_links(self, observable_key: str) -> None:
|
|
299
315
|
obs = self._observables.get(observable_key)
|
|
300
316
|
if not obs:
|
|
301
317
|
return
|
|
302
318
|
check_keys = self._score_engine.get_check_links_for_observable(observable_key)
|
|
303
319
|
obs._check_links = check_keys
|
|
304
320
|
|
|
305
|
-
def
|
|
321
|
+
def _rebuild_all_check_links(self) -> None:
|
|
306
322
|
for observable_key in self._observables:
|
|
307
|
-
self.
|
|
323
|
+
self._update_observable_check_links(observable_key)
|
|
308
324
|
|
|
309
325
|
def get_audit_log(self) -> list[AuditEvent]:
|
|
310
326
|
"""Return a deep copy of the audit log."""
|
|
@@ -342,8 +358,6 @@ class Investigation:
|
|
|
342
358
|
details={"old_name": old_name, "new_name": name},
|
|
343
359
|
)
|
|
344
360
|
|
|
345
|
-
# Private merge methods
|
|
346
|
-
|
|
347
361
|
def _merge_observable(self, existing: Observable, incoming: Observable) -> tuple[Observable, list]:
|
|
348
362
|
"""
|
|
349
363
|
Merge an incoming observable into an existing observable.
|
|
@@ -352,7 +366,7 @@ class Investigation:
|
|
|
352
366
|
- Update score (take maximum)
|
|
353
367
|
- Update level (take maximum)
|
|
354
368
|
- Update extra (merge dicts)
|
|
355
|
-
-
|
|
369
|
+
- Overwrite comment (if incoming is non-empty)
|
|
356
370
|
- Merge threat intels
|
|
357
371
|
- Merge relationships (defer if target missing)
|
|
358
372
|
- Preserve provenance metadata
|
|
@@ -383,12 +397,9 @@ class Investigation:
|
|
|
383
397
|
elif incoming.extra:
|
|
384
398
|
existing.extra = dict(incoming.extra)
|
|
385
399
|
|
|
386
|
-
#
|
|
400
|
+
# Overwrite comment if incoming is non-empty
|
|
387
401
|
if incoming.comment:
|
|
388
|
-
|
|
389
|
-
existing.comment += "\n\n" + incoming.comment
|
|
390
|
-
else:
|
|
391
|
-
existing.comment = incoming.comment
|
|
402
|
+
existing.comment = incoming.comment
|
|
392
403
|
|
|
393
404
|
# Merge whitelisted status (if either is whitelisted, result is whitelisted)
|
|
394
405
|
existing.whitelisted = existing.whitelisted or incoming.whitelisted
|
|
@@ -400,7 +411,7 @@ class Investigation:
|
|
|
400
411
|
existing_ti_keys = {ti.key for ti in existing.threat_intels}
|
|
401
412
|
for ti in incoming.threat_intels:
|
|
402
413
|
if ti.key not in existing_ti_keys:
|
|
403
|
-
self.
|
|
414
|
+
self._link_threat_intel_to_observable(existing, ti)
|
|
404
415
|
existing_ti_keys.add(ti.key)
|
|
405
416
|
|
|
406
417
|
# Merge relationships (defer if target not yet available)
|
|
@@ -408,7 +419,7 @@ class Investigation:
|
|
|
408
419
|
for rel in incoming.relationships:
|
|
409
420
|
if rel.target_key in self._observables:
|
|
410
421
|
# Target exists - add relationship immediately
|
|
411
|
-
self.
|
|
422
|
+
self._create_relationship(existing, rel.target_key, rel.relationship_type, rel.direction)
|
|
412
423
|
else:
|
|
413
424
|
# Target doesn't exist yet - defer for Pass 2 of merge_investigation()
|
|
414
425
|
deferred_relationships.append((existing.key, rel))
|
|
@@ -423,7 +434,7 @@ class Investigation:
|
|
|
423
434
|
- Update score (take maximum)
|
|
424
435
|
- Update level (take maximum)
|
|
425
436
|
- Update extra (merge dicts)
|
|
426
|
-
-
|
|
437
|
+
- Overwrite comment (if incoming is non-empty)
|
|
427
438
|
- Merge observable links (tuple-based deduplication, provenance-preserving)
|
|
428
439
|
|
|
429
440
|
Args:
|
|
@@ -453,12 +464,9 @@ class Investigation:
|
|
|
453
464
|
# Update extra (merge dictionaries)
|
|
454
465
|
existing.extra.update(incoming.extra)
|
|
455
466
|
|
|
456
|
-
#
|
|
467
|
+
# Overwrite comment if incoming is non-empty
|
|
457
468
|
if incoming.comment:
|
|
458
|
-
|
|
459
|
-
existing.comment += "\n\n" + incoming.comment
|
|
460
|
-
else:
|
|
461
|
-
existing.comment = incoming.comment
|
|
469
|
+
existing.comment = incoming.comment
|
|
462
470
|
|
|
463
471
|
existing_by_tuple: dict[tuple[str, PropagationMode], int] = {}
|
|
464
472
|
for idx, existing_link in enumerate(existing.observable_links):
|
|
@@ -561,20 +569,19 @@ class Investigation:
|
|
|
561
569
|
|
|
562
570
|
return existing
|
|
563
571
|
|
|
564
|
-
def
|
|
572
|
+
def _merge_tag(self, existing: Tag, incoming: Tag) -> Tag:
|
|
565
573
|
"""
|
|
566
|
-
Merge an incoming
|
|
574
|
+
Merge an incoming tag into an existing tag.
|
|
567
575
|
|
|
568
576
|
Strategy:
|
|
569
577
|
- Merge checks (dict-based lookup for efficiency)
|
|
570
|
-
- Merge sub-containers recursively
|
|
571
578
|
|
|
572
579
|
Args:
|
|
573
|
-
existing: The existing
|
|
574
|
-
incoming: The incoming
|
|
580
|
+
existing: The existing tag
|
|
581
|
+
incoming: The incoming tag to merge
|
|
575
582
|
|
|
576
583
|
Returns:
|
|
577
|
-
The merged
|
|
584
|
+
The merged tag (existing is modified in place)
|
|
578
585
|
"""
|
|
579
586
|
# Update description if incoming has one
|
|
580
587
|
if incoming.description:
|
|
@@ -589,20 +596,80 @@ class Investigation:
|
|
|
589
596
|
self._merge_check(existing_checks_dict[incoming_check.key], incoming_check)
|
|
590
597
|
else:
|
|
591
598
|
# Add new check
|
|
592
|
-
self.
|
|
593
|
-
|
|
594
|
-
# Merge sub-containers recursively
|
|
595
|
-
for sub_key, incoming_sub in incoming.sub_containers.items():
|
|
596
|
-
if sub_key in existing.sub_containers:
|
|
597
|
-
# Merge existing sub-container
|
|
598
|
-
self._merge_container(existing.sub_containers[sub_key], incoming_sub)
|
|
599
|
-
else:
|
|
600
|
-
# Add new sub-container
|
|
601
|
-
self._container_add_sub_container(existing, incoming_sub)
|
|
599
|
+
self._link_check_to_tag(existing, incoming_check)
|
|
602
600
|
|
|
603
601
|
return existing
|
|
604
602
|
|
|
605
|
-
|
|
603
|
+
def _clone_for_merge(
|
|
604
|
+
self, other: Investigation
|
|
605
|
+
) -> tuple[
|
|
606
|
+
dict[str, Observable],
|
|
607
|
+
dict[str, ThreatIntel],
|
|
608
|
+
dict[str, Check],
|
|
609
|
+
dict[str, Enrichment],
|
|
610
|
+
dict[str, Tag],
|
|
611
|
+
]:
|
|
612
|
+
"""Clone incoming models while preserving shared object references."""
|
|
613
|
+
incoming_threat_intels = {key: ti.model_copy(deep=True) for key, ti in other._threat_intels.items()}
|
|
614
|
+
incoming_checks = {key: check.model_copy(deep=True) for key, check in other._checks.items()}
|
|
615
|
+
incoming_enrichments = {key: enrichment.model_copy(deep=True) for key, enrichment in other._enrichments.items()}
|
|
616
|
+
|
|
617
|
+
orphan_threat_intels: dict[str, ThreatIntel] = {}
|
|
618
|
+
|
|
619
|
+
def _copy_threat_intel(ti: ThreatIntel) -> ThreatIntel:
|
|
620
|
+
if ti.key in incoming_threat_intels:
|
|
621
|
+
return incoming_threat_intels[ti.key]
|
|
622
|
+
existing = orphan_threat_intels.get(ti.key)
|
|
623
|
+
if existing:
|
|
624
|
+
return existing
|
|
625
|
+
copied = ti.model_copy(deep=True)
|
|
626
|
+
orphan_threat_intels[ti.key] = copied
|
|
627
|
+
return copied
|
|
628
|
+
|
|
629
|
+
incoming_observables: dict[str, Observable] = {}
|
|
630
|
+
for obs in other._observables.values():
|
|
631
|
+
copied_obs = obs.model_copy(deep=True)
|
|
632
|
+
if obs.threat_intels:
|
|
633
|
+
copied_obs.threat_intels = [_copy_threat_intel(ti) for ti in obs.threat_intels]
|
|
634
|
+
incoming_observables[obs.key] = copied_obs
|
|
635
|
+
|
|
636
|
+
orphan_checks: dict[str, Check] = {}
|
|
637
|
+
|
|
638
|
+
def _copy_check(check: Check) -> Check:
|
|
639
|
+
if check.key in incoming_checks:
|
|
640
|
+
return incoming_checks[check.key]
|
|
641
|
+
existing = orphan_checks.get(check.key)
|
|
642
|
+
if existing:
|
|
643
|
+
return existing
|
|
644
|
+
copied = check.model_copy(deep=True)
|
|
645
|
+
orphan_checks[check.key] = copied
|
|
646
|
+
return copied
|
|
647
|
+
|
|
648
|
+
incoming_tags: dict[str, Tag] = {}
|
|
649
|
+
|
|
650
|
+
def _copy_tag(tag: Tag) -> Tag:
|
|
651
|
+
existing = incoming_tags.get(tag.key)
|
|
652
|
+
if existing:
|
|
653
|
+
return existing
|
|
654
|
+
copied = Tag(
|
|
655
|
+
name=tag.name,
|
|
656
|
+
description=tag.description,
|
|
657
|
+
checks=[_copy_check(check) for check in tag.checks],
|
|
658
|
+
key=tag.key,
|
|
659
|
+
)
|
|
660
|
+
incoming_tags[tag.key] = copied
|
|
661
|
+
return copied
|
|
662
|
+
|
|
663
|
+
for tag in other._tags.values():
|
|
664
|
+
_copy_tag(tag)
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
incoming_observables,
|
|
668
|
+
incoming_threat_intels,
|
|
669
|
+
incoming_checks,
|
|
670
|
+
incoming_enrichments,
|
|
671
|
+
incoming_tags,
|
|
672
|
+
)
|
|
606
673
|
|
|
607
674
|
def add_observable(self, obs: Observable) -> tuple[Observable, list]:
|
|
608
675
|
"""
|
|
@@ -623,7 +690,7 @@ class Investigation:
|
|
|
623
690
|
self._observables[obs.key] = obs
|
|
624
691
|
self._score_engine.register_observable(obs)
|
|
625
692
|
self._stats.register_observable(obs)
|
|
626
|
-
self.
|
|
693
|
+
self._update_observable_check_links(obs.key)
|
|
627
694
|
self._record_event(
|
|
628
695
|
event_type="OBSERVABLE_CREATED",
|
|
629
696
|
object_type="observable",
|
|
@@ -646,7 +713,7 @@ class Investigation:
|
|
|
646
713
|
self._score_engine.rebuild_link_index()
|
|
647
714
|
self._score_engine.recalculate_all()
|
|
648
715
|
for link in r.observable_links:
|
|
649
|
-
self.
|
|
716
|
+
self._update_observable_check_links(link.observable_key)
|
|
650
717
|
return r
|
|
651
718
|
|
|
652
719
|
if not getattr(check, "origin_investigation_id", None):
|
|
@@ -657,7 +724,7 @@ class Investigation:
|
|
|
657
724
|
self._score_engine.register_check(check)
|
|
658
725
|
self._stats.register_check(check)
|
|
659
726
|
for link in check.observable_links:
|
|
660
|
-
self.
|
|
727
|
+
self._update_observable_check_links(link.observable_key)
|
|
661
728
|
self._record_event(
|
|
662
729
|
event_type="CHECK_CREATED",
|
|
663
730
|
object_type="check",
|
|
@@ -698,7 +765,7 @@ class Investigation:
|
|
|
698
765
|
self._stats.register_threat_intel(ti)
|
|
699
766
|
|
|
700
767
|
# Add to observable
|
|
701
|
-
self.
|
|
768
|
+
self._link_threat_intel_to_observable(observable, ti)
|
|
702
769
|
|
|
703
770
|
# Propagate score
|
|
704
771
|
self._score_engine.propagate_threat_intel_to_observable(ti, observable)
|
|
@@ -787,32 +854,51 @@ class Investigation:
|
|
|
787
854
|
)
|
|
788
855
|
return enrichment
|
|
789
856
|
|
|
790
|
-
def
|
|
857
|
+
def add_tag(self, tag: Tag) -> Tag:
|
|
791
858
|
"""
|
|
792
|
-
Add or merge
|
|
859
|
+
Add or merge a tag, automatically creating ancestor tags.
|
|
860
|
+
|
|
861
|
+
When adding a tag with a hierarchical name (using ":" delimiter),
|
|
862
|
+
ancestor tags are automatically created if they don't exist.
|
|
863
|
+
For example, adding "header:auth:dkim" will auto-create
|
|
864
|
+
"header" and "header:auth" tags.
|
|
793
865
|
|
|
794
866
|
Args:
|
|
795
|
-
|
|
867
|
+
tag: Tag to add or merge
|
|
796
868
|
|
|
797
869
|
Returns:
|
|
798
|
-
The resulting
|
|
870
|
+
The resulting tag (either new or merged)
|
|
799
871
|
"""
|
|
800
|
-
|
|
801
|
-
|
|
872
|
+
# Auto-create ancestor tags
|
|
873
|
+
ancestor_names = keys.get_tag_ancestors(tag.name)
|
|
874
|
+
for ancestor_name in ancestor_names:
|
|
875
|
+
ancestor_key = keys.generate_tag_key(ancestor_name)
|
|
876
|
+
if ancestor_key not in self._tags:
|
|
877
|
+
ancestor_tag = Tag(name=ancestor_name)
|
|
878
|
+
self._tags[ancestor_key] = ancestor_tag
|
|
879
|
+
self._stats.register_tag(ancestor_tag)
|
|
880
|
+
self._record_event(
|
|
881
|
+
event_type="TAG_CREATED",
|
|
882
|
+
object_type="tag",
|
|
883
|
+
object_key=ancestor_key,
|
|
884
|
+
details={"auto_created": True, "descendant": tag.name},
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
# Add or merge the tag itself
|
|
888
|
+
if tag.key in self._tags:
|
|
889
|
+
r = self._merge_tag(self._tags[tag.key], tag)
|
|
802
890
|
self._score_engine.recalculate_all()
|
|
803
891
|
return r
|
|
804
892
|
|
|
805
|
-
# Register new
|
|
806
|
-
self.
|
|
807
|
-
self._stats.
|
|
893
|
+
# Register new tag
|
|
894
|
+
self._tags[tag.key] = tag
|
|
895
|
+
self._stats.register_tag(tag)
|
|
808
896
|
self._record_event(
|
|
809
|
-
event_type="
|
|
810
|
-
object_type="
|
|
811
|
-
object_key=
|
|
897
|
+
event_type="TAG_CREATED",
|
|
898
|
+
object_type="tag",
|
|
899
|
+
object_key=tag.key,
|
|
812
900
|
)
|
|
813
|
-
return
|
|
814
|
-
|
|
815
|
-
# Relationship and linking methods
|
|
901
|
+
return tag
|
|
816
902
|
|
|
817
903
|
def add_relationship(
|
|
818
904
|
self,
|
|
@@ -868,7 +954,7 @@ class Investigation:
|
|
|
868
954
|
raise KeyError(f"observable '{target_key}' not found in investigation.")
|
|
869
955
|
|
|
870
956
|
# Add relationship using internal method
|
|
871
|
-
self.
|
|
957
|
+
self._create_relationship(source_obs, target_key, relationship_type, direction)
|
|
872
958
|
|
|
873
959
|
self._record_event(
|
|
874
960
|
event_type="RELATIONSHIP_CREATED",
|
|
@@ -920,10 +1006,10 @@ class Investigation:
|
|
|
920
1006
|
observable_key=observable_key,
|
|
921
1007
|
propagation_mode=propagation_mode,
|
|
922
1008
|
)
|
|
923
|
-
created = self.
|
|
1009
|
+
created = self._link_check_to_observable(check, link)
|
|
924
1010
|
if created:
|
|
925
1011
|
self._score_engine.register_check_observable_link(check_key=check.key, observable_key=observable_key)
|
|
926
|
-
self.
|
|
1012
|
+
self._update_observable_check_links(observable_key)
|
|
927
1013
|
self._record_event(
|
|
928
1014
|
event_type="CHECK_LINKED_TO_OBSERVABLE",
|
|
929
1015
|
object_type="check",
|
|
@@ -943,85 +1029,133 @@ class Investigation:
|
|
|
943
1029
|
|
|
944
1030
|
return check
|
|
945
1031
|
|
|
946
|
-
def
|
|
1032
|
+
def add_check_to_tag(self, tag_key: str, check_key: str) -> Tag:
|
|
947
1033
|
"""
|
|
948
|
-
Add a check to a
|
|
1034
|
+
Add a check to a tag.
|
|
949
1035
|
|
|
950
1036
|
Args:
|
|
951
|
-
|
|
1037
|
+
tag_key: Key of the tag
|
|
952
1038
|
check_key: Key of the check
|
|
953
1039
|
|
|
954
1040
|
Returns:
|
|
955
|
-
The
|
|
1041
|
+
The tag
|
|
956
1042
|
|
|
957
1043
|
Raises:
|
|
958
|
-
KeyError: If the
|
|
1044
|
+
KeyError: If the tag or check does not exist
|
|
959
1045
|
"""
|
|
960
|
-
|
|
1046
|
+
tag = self._tags.get(tag_key)
|
|
961
1047
|
check = self._checks.get(check_key)
|
|
962
1048
|
|
|
963
|
-
if
|
|
964
|
-
raise KeyError(f"
|
|
1049
|
+
if tag is None:
|
|
1050
|
+
raise KeyError(f"tag '{tag_key}' not found in investigation.")
|
|
965
1051
|
if check is None:
|
|
966
1052
|
raise KeyError(f"check '{check_key}' not found in investigation.")
|
|
967
1053
|
|
|
968
|
-
if
|
|
969
|
-
self.
|
|
1054
|
+
if tag and check:
|
|
1055
|
+
self._link_check_to_tag(tag, check)
|
|
970
1056
|
self._record_event(
|
|
971
|
-
event_type="
|
|
972
|
-
object_type="
|
|
973
|
-
object_key=
|
|
1057
|
+
event_type="TAG_CHECK_ADDED",
|
|
1058
|
+
object_type="tag",
|
|
1059
|
+
object_key=tag.key,
|
|
974
1060
|
details={"check_key": check.key},
|
|
975
1061
|
)
|
|
976
1062
|
|
|
977
|
-
return
|
|
1063
|
+
return tag
|
|
1064
|
+
|
|
1065
|
+
def get_root(self) -> Observable:
|
|
1066
|
+
"""Get the root observable."""
|
|
1067
|
+
return self._root_observable
|
|
1068
|
+
|
|
1069
|
+
def get_observable(self, key: str) -> Observable | None:
|
|
1070
|
+
"""Get observable by full key string."""
|
|
1071
|
+
return self._observables.get(key)
|
|
1072
|
+
|
|
1073
|
+
def get_check(self, key: str) -> Check | None:
|
|
1074
|
+
"""Get check by full key string."""
|
|
1075
|
+
return self._checks.get(key)
|
|
1076
|
+
|
|
1077
|
+
def get_tag(self, key: str) -> Tag | None:
|
|
1078
|
+
"""Get a tag by key."""
|
|
1079
|
+
return self._tags.get(key)
|
|
978
1080
|
|
|
979
|
-
def
|
|
1081
|
+
def get_tag_children(self, tag_name: str) -> list[Tag]:
|
|
980
1082
|
"""
|
|
981
|
-
|
|
1083
|
+
Get direct child tags of a tag.
|
|
982
1084
|
|
|
983
1085
|
Args:
|
|
984
|
-
|
|
985
|
-
child_key: Key of the child container
|
|
1086
|
+
tag_name: Name of the parent tag
|
|
986
1087
|
|
|
987
1088
|
Returns:
|
|
988
|
-
|
|
1089
|
+
List of direct child Tag objects
|
|
1090
|
+
"""
|
|
1091
|
+
return [t for t in self._tags.values() if keys.is_tag_child_of(t.name, tag_name)]
|
|
989
1092
|
|
|
990
|
-
|
|
991
|
-
KeyError: If the parent or child container does not exist
|
|
1093
|
+
def get_tag_descendants(self, tag_name: str) -> list[Tag]:
|
|
992
1094
|
"""
|
|
993
|
-
|
|
994
|
-
child = self._containers.get(child_key)
|
|
1095
|
+
Get all descendant tags of a tag.
|
|
995
1096
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
if child is None:
|
|
999
|
-
raise KeyError(f"container '{child_key}' not found in investigation.")
|
|
1097
|
+
Args:
|
|
1098
|
+
tag_name: Name of the ancestor tag
|
|
1000
1099
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
object_type="container",
|
|
1006
|
-
object_key=parent.key,
|
|
1007
|
-
details={"child_container_key": child.key},
|
|
1008
|
-
)
|
|
1100
|
+
Returns:
|
|
1101
|
+
List of all descendant Tag objects
|
|
1102
|
+
"""
|
|
1103
|
+
return [t for t in self._tags.values() if keys.is_tag_descendant_of(t.name, tag_name)]
|
|
1009
1104
|
|
|
1010
|
-
|
|
1105
|
+
def get_tag_ancestors(self, tag_name: str) -> list[Tag]:
|
|
1106
|
+
"""
|
|
1107
|
+
Get all ancestor tags of a tag.
|
|
1011
1108
|
|
|
1012
|
-
|
|
1109
|
+
Args:
|
|
1110
|
+
tag_name: Name of the descendant tag
|
|
1013
1111
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1112
|
+
Returns:
|
|
1113
|
+
List of ancestor Tag objects (in order from root to immediate parent)
|
|
1114
|
+
"""
|
|
1115
|
+
ancestor_names = keys.get_tag_ancestors(tag_name)
|
|
1116
|
+
result = []
|
|
1117
|
+
for name in ancestor_names:
|
|
1118
|
+
tag_key = keys.generate_tag_key(name)
|
|
1119
|
+
if tag_key in self._tags:
|
|
1120
|
+
result.append(self._tags[tag_key])
|
|
1121
|
+
return result
|
|
1122
|
+
|
|
1123
|
+
def get_tag_aggregated_score(self, tag_name: str) -> Decimal:
|
|
1124
|
+
"""
|
|
1125
|
+
Get aggregated score for a tag including all descendants.
|
|
1017
1126
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1127
|
+
Args:
|
|
1128
|
+
tag_name: Name of the tag
|
|
1129
|
+
|
|
1130
|
+
Returns:
|
|
1131
|
+
Total score from direct checks and all descendant tag checks
|
|
1132
|
+
"""
|
|
1133
|
+
tag_key = keys.generate_tag_key(tag_name)
|
|
1134
|
+
tag = self._tags.get(tag_key)
|
|
1135
|
+
if not tag:
|
|
1136
|
+
return Decimal("0")
|
|
1137
|
+
|
|
1138
|
+
total = tag.get_direct_score()
|
|
1139
|
+
|
|
1140
|
+
# Add scores from direct children only (they will recursively add their children)
|
|
1141
|
+
for child in self.get_tag_children(tag_name):
|
|
1142
|
+
total += self.get_tag_aggregated_score(child.name)
|
|
1143
|
+
|
|
1144
|
+
return total
|
|
1145
|
+
|
|
1146
|
+
def get_tag_aggregated_level(self, tag_name: str) -> Level:
|
|
1147
|
+
"""
|
|
1148
|
+
Get aggregated level for a tag including all descendants.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
tag_name: Name of the tag
|
|
1152
|
+
|
|
1153
|
+
Returns:
|
|
1154
|
+
Level based on aggregated score
|
|
1155
|
+
"""
|
|
1156
|
+
from cyvest.levels import get_level_from_score
|
|
1021
1157
|
|
|
1022
|
-
|
|
1023
|
-
"""Get a container by key."""
|
|
1024
|
-
return self._containers.get(key)
|
|
1158
|
+
return get_level_from_score(self.get_tag_aggregated_score(tag_name))
|
|
1025
1159
|
|
|
1026
1160
|
def get_enrichment(self, key: str) -> Enrichment | None:
|
|
1027
1161
|
"""Get an enrichment by key."""
|
|
@@ -1031,31 +1165,9 @@ class Investigation:
|
|
|
1031
1165
|
"""Get a threat intel by key."""
|
|
1032
1166
|
return self._threat_intels.get(key)
|
|
1033
1167
|
|
|
1034
|
-
@staticmethod
|
|
1035
|
-
def _normalize_taxonomies(value: Any) -> list[Taxonomy]:
|
|
1036
|
-
if value is None:
|
|
1037
|
-
return []
|
|
1038
|
-
if not isinstance(value, list):
|
|
1039
|
-
raise TypeError("taxonomies must be a list of taxonomy objects.")
|
|
1040
|
-
taxonomies = [Taxonomy.model_validate(item) for item in value]
|
|
1041
|
-
seen: set[str] = set()
|
|
1042
|
-
duplicates: set[str] = set()
|
|
1043
|
-
for taxonomy in taxonomies:
|
|
1044
|
-
if taxonomy.name in seen:
|
|
1045
|
-
duplicates.add(taxonomy.name)
|
|
1046
|
-
seen.add(taxonomy.name)
|
|
1047
|
-
if duplicates:
|
|
1048
|
-
dupes = ", ".join(sorted(duplicates))
|
|
1049
|
-
raise ValueError(f"Duplicate taxonomy name(s): {dupes}")
|
|
1050
|
-
return taxonomies
|
|
1051
|
-
|
|
1052
|
-
def get_root(self) -> Observable:
|
|
1053
|
-
"""Get the root observable."""
|
|
1054
|
-
return self._root_observable
|
|
1055
|
-
|
|
1056
1168
|
def update_model_metadata(
|
|
1057
1169
|
self,
|
|
1058
|
-
model_type: Literal["observable", "check", "threat_intel", "enrichment", "
|
|
1170
|
+
model_type: Literal["observable", "check", "threat_intel", "enrichment", "tag"],
|
|
1059
1171
|
key: str,
|
|
1060
1172
|
updates: dict[str, Any],
|
|
1061
1173
|
*,
|
|
@@ -1083,7 +1195,7 @@ class Investigation:
|
|
|
1083
1195
|
"check": self._checks,
|
|
1084
1196
|
"threat_intel": self._threat_intels,
|
|
1085
1197
|
"enrichment": self._enrichments,
|
|
1086
|
-
"
|
|
1198
|
+
"tag": self._tags,
|
|
1087
1199
|
}
|
|
1088
1200
|
store = store_lookup[model_type]
|
|
1089
1201
|
target = store.get(key)
|
|
@@ -1152,11 +1264,9 @@ class Investigation:
|
|
|
1152
1264
|
"""Get all enrichments."""
|
|
1153
1265
|
return self._enrichments.copy()
|
|
1154
1266
|
|
|
1155
|
-
def
|
|
1156
|
-
"""Get all
|
|
1157
|
-
return self.
|
|
1158
|
-
|
|
1159
|
-
# Scoring and statistics
|
|
1267
|
+
def get_all_tags(self) -> dict[str, Tag]:
|
|
1268
|
+
"""Get all tags."""
|
|
1269
|
+
return self._tags.copy()
|
|
1160
1270
|
|
|
1161
1271
|
def get_global_score(self) -> Decimal:
|
|
1162
1272
|
"""Get the global investigation score."""
|
|
@@ -1313,7 +1423,7 @@ class Investigation:
|
|
|
1313
1423
|
|
|
1314
1424
|
# Link the best starting node to root
|
|
1315
1425
|
if best_node:
|
|
1316
|
-
self.
|
|
1426
|
+
self._create_relationship(self._root_observable, best_node, RelationshipType.RELATED_TO)
|
|
1317
1427
|
self._record_event(
|
|
1318
1428
|
event_type="RELATIONSHIP_CREATED",
|
|
1319
1429
|
object_type="observable",
|
|
@@ -1327,8 +1437,6 @@ class Investigation:
|
|
|
1327
1437
|
)
|
|
1328
1438
|
self._score_engine.recalculate_all()
|
|
1329
1439
|
|
|
1330
|
-
# Investigation merging
|
|
1331
|
-
|
|
1332
1440
|
def merge_investigation(self, other: Investigation) -> None:
|
|
1333
1441
|
"""
|
|
1334
1442
|
Merge another investigation into this one.
|
|
@@ -1397,11 +1505,10 @@ class Investigation:
|
|
|
1397
1505
|
"data": deepcopy(enrichment.data),
|
|
1398
1506
|
}
|
|
1399
1507
|
|
|
1400
|
-
def
|
|
1508
|
+
def _snapshot_tag(tag: Tag) -> dict[str, Any]:
|
|
1401
1509
|
return {
|
|
1402
|
-
"description":
|
|
1403
|
-
"checks": sorted(check.key for check in
|
|
1404
|
-
"sub_containers": sorted(container.sub_containers.keys()),
|
|
1510
|
+
"description": tag.description,
|
|
1511
|
+
"checks": sorted(check.key for check in tag.checks),
|
|
1405
1512
|
}
|
|
1406
1513
|
|
|
1407
1514
|
merge_summary: list[dict[str, Any]] = []
|
|
@@ -1411,7 +1518,7 @@ class Investigation:
|
|
|
1411
1518
|
incoming_threat_intels,
|
|
1412
1519
|
incoming_checks,
|
|
1413
1520
|
incoming_enrichments,
|
|
1414
|
-
|
|
1521
|
+
incoming_tags,
|
|
1415
1522
|
) = self._clone_for_merge(other)
|
|
1416
1523
|
|
|
1417
1524
|
# PASS 1: Merge observables and collect deferred relationships
|
|
@@ -1448,7 +1555,7 @@ class Investigation:
|
|
|
1448
1555
|
source_obs = self._observables.get(source_key)
|
|
1449
1556
|
if source_obs and rel.target_key in self._observables:
|
|
1450
1557
|
# Both source and target exist - add relationship
|
|
1451
|
-
self.
|
|
1558
|
+
self._create_relationship(source_obs, rel.target_key, rel.relationship_type, rel.direction)
|
|
1452
1559
|
else:
|
|
1453
1560
|
# Genuine error - target still doesn't exist after Pass 2
|
|
1454
1561
|
logger.critical(
|
|
@@ -1524,13 +1631,13 @@ class Investigation:
|
|
|
1524
1631
|
}
|
|
1525
1632
|
)
|
|
1526
1633
|
|
|
1527
|
-
# Merge
|
|
1528
|
-
for
|
|
1529
|
-
|
|
1530
|
-
before =
|
|
1531
|
-
self.
|
|
1532
|
-
if
|
|
1533
|
-
after =
|
|
1634
|
+
# Merge tags
|
|
1635
|
+
for tag in incoming_tags.values():
|
|
1636
|
+
existing_tag = self._tags.get(tag.key)
|
|
1637
|
+
before = _snapshot_tag(existing_tag) if existing_tag else None
|
|
1638
|
+
self.add_tag(tag)
|
|
1639
|
+
if existing_tag:
|
|
1640
|
+
after = _snapshot_tag(existing_tag)
|
|
1534
1641
|
changed_fields = _diff_fields(before, after) if before else []
|
|
1535
1642
|
action = "merged" if changed_fields else "skipped"
|
|
1536
1643
|
else:
|
|
@@ -1538,8 +1645,8 @@ class Investigation:
|
|
|
1538
1645
|
action = "created"
|
|
1539
1646
|
merge_summary.append(
|
|
1540
1647
|
{
|
|
1541
|
-
"object_type": "
|
|
1542
|
-
"object_key":
|
|
1648
|
+
"object_type": "tag",
|
|
1649
|
+
"object_key": tag.key,
|
|
1543
1650
|
"action": action,
|
|
1544
1651
|
"changed_fields": changed_fields,
|
|
1545
1652
|
}
|
|
@@ -1551,7 +1658,7 @@ class Investigation:
|
|
|
1551
1658
|
|
|
1552
1659
|
# Rebuild link index after merges
|
|
1553
1660
|
self._score_engine.rebuild_link_index()
|
|
1554
|
-
self.
|
|
1661
|
+
self._rebuild_all_check_links()
|
|
1555
1662
|
|
|
1556
1663
|
# Final score recalculation
|
|
1557
1664
|
self._score_engine.recalculate_all()
|
|
@@ -1568,77 +1675,3 @@ class Investigation:
|
|
|
1568
1675
|
"object_changes": merge_summary,
|
|
1569
1676
|
},
|
|
1570
1677
|
)
|
|
1571
|
-
|
|
1572
|
-
def _clone_for_merge(
|
|
1573
|
-
self, other: Investigation
|
|
1574
|
-
) -> tuple[
|
|
1575
|
-
dict[str, Observable],
|
|
1576
|
-
dict[str, ThreatIntel],
|
|
1577
|
-
dict[str, Check],
|
|
1578
|
-
dict[str, Enrichment],
|
|
1579
|
-
dict[str, Container],
|
|
1580
|
-
]:
|
|
1581
|
-
"""Clone incoming models while preserving shared object references."""
|
|
1582
|
-
incoming_threat_intels = {key: ti.model_copy(deep=True) for key, ti in other._threat_intels.items()}
|
|
1583
|
-
incoming_checks = {key: check.model_copy(deep=True) for key, check in other._checks.items()}
|
|
1584
|
-
incoming_enrichments = {key: enrichment.model_copy(deep=True) for key, enrichment in other._enrichments.items()}
|
|
1585
|
-
|
|
1586
|
-
orphan_threat_intels: dict[str, ThreatIntel] = {}
|
|
1587
|
-
|
|
1588
|
-
def _copy_threat_intel(ti: ThreatIntel) -> ThreatIntel:
|
|
1589
|
-
if ti.key in incoming_threat_intels:
|
|
1590
|
-
return incoming_threat_intels[ti.key]
|
|
1591
|
-
existing = orphan_threat_intels.get(ti.key)
|
|
1592
|
-
if existing:
|
|
1593
|
-
return existing
|
|
1594
|
-
copied = ti.model_copy(deep=True)
|
|
1595
|
-
orphan_threat_intels[ti.key] = copied
|
|
1596
|
-
return copied
|
|
1597
|
-
|
|
1598
|
-
incoming_observables: dict[str, Observable] = {}
|
|
1599
|
-
for obs in other._observables.values():
|
|
1600
|
-
copied_obs = obs.model_copy(deep=True)
|
|
1601
|
-
if obs.threat_intels:
|
|
1602
|
-
copied_obs.threat_intels = [_copy_threat_intel(ti) for ti in obs.threat_intels]
|
|
1603
|
-
incoming_observables[obs.key] = copied_obs
|
|
1604
|
-
|
|
1605
|
-
orphan_checks: dict[str, Check] = {}
|
|
1606
|
-
|
|
1607
|
-
def _copy_check(check: Check) -> Check:
|
|
1608
|
-
if check.key in incoming_checks:
|
|
1609
|
-
return incoming_checks[check.key]
|
|
1610
|
-
existing = orphan_checks.get(check.key)
|
|
1611
|
-
if existing:
|
|
1612
|
-
return existing
|
|
1613
|
-
copied = check.model_copy(deep=True)
|
|
1614
|
-
orphan_checks[check.key] = copied
|
|
1615
|
-
return copied
|
|
1616
|
-
|
|
1617
|
-
incoming_containers: dict[str, Container] = {}
|
|
1618
|
-
|
|
1619
|
-
def _copy_container(container: Container) -> Container:
|
|
1620
|
-
existing = incoming_containers.get(container.key)
|
|
1621
|
-
if existing:
|
|
1622
|
-
return existing
|
|
1623
|
-
copied = Container(
|
|
1624
|
-
path=container.path,
|
|
1625
|
-
description=container.description,
|
|
1626
|
-
checks=[_copy_check(check) for check in container.checks],
|
|
1627
|
-
sub_containers={},
|
|
1628
|
-
key=container.key,
|
|
1629
|
-
)
|
|
1630
|
-
incoming_containers[container.key] = copied
|
|
1631
|
-
for sub_key, sub in container.sub_containers.items():
|
|
1632
|
-
copied.sub_containers[sub_key] = _copy_container(sub)
|
|
1633
|
-
return copied
|
|
1634
|
-
|
|
1635
|
-
for container in other._containers.values():
|
|
1636
|
-
_copy_container(container)
|
|
1637
|
-
|
|
1638
|
-
return (
|
|
1639
|
-
incoming_observables,
|
|
1640
|
-
incoming_threat_intels,
|
|
1641
|
-
incoming_checks,
|
|
1642
|
-
incoming_enrichments,
|
|
1643
|
-
incoming_containers,
|
|
1644
|
-
)
|