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/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
- "container": {
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._containers: dict[str, Container] = {}
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 _add_threat_intel_to_observable(self, observable: Observable, ti: ThreatIntel) -> None:
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 _add_relationship_internal(
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 _add_check_observable_link(self, check: Check, link: ObservableLink) -> bool:
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 _container_add_check(self, container: Container, check: Check) -> None:
205
- if any(existing.key == check.key for existing in container.checks):
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
- container.checks.append(check)
208
+ tag.checks.append(check)
208
209
 
209
- def _container_add_sub_container(self, parent: Container, child: Container) -> None:
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, Container):
222
- return "container"
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._infer_object_type(obj),
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._infer_object_type(obj),
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 _sync_check_links_for_observable(self, observable_key: str) -> None:
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 _refresh_check_links(self) -> None:
321
+ def _rebuild_all_check_links(self) -> None:
306
322
  for observable_key in self._observables:
307
- self._sync_check_links_for_observable(observable_key)
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
- - Concatenate comments
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
- # Concatenate comments
400
+ # Overwrite comment if incoming is non-empty
387
401
  if incoming.comment:
388
- if existing.comment:
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._add_threat_intel_to_observable(existing, ti)
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._add_relationship_internal(existing, rel.target_key, rel.relationship_type, rel.direction)
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
- - Concatenate comments
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
- # Concatenate comments
467
+ # Overwrite comment if incoming is non-empty
457
468
  if incoming.comment:
458
- if existing.comment:
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 _merge_container(self, existing: Container, incoming: Container) -> Container:
572
+ def _merge_tag(self, existing: Tag, incoming: Tag) -> Tag:
565
573
  """
566
- Merge an incoming container into an existing container.
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 container
574
- incoming: The incoming container to merge
580
+ existing: The existing tag
581
+ incoming: The incoming tag to merge
575
582
 
576
583
  Returns:
577
- The merged container (existing is modified in place)
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._container_add_check(existing, incoming_check)
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
- # Public add methods with merge-on-create
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._sync_check_links_for_observable(obs.key)
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._sync_check_links_for_observable(link.observable_key)
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._sync_check_links_for_observable(link.observable_key)
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._add_threat_intel_to_observable(observable, ti)
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 add_container(self, container: Container) -> Container:
857
+ def add_tag(self, tag: Tag) -> Tag:
791
858
  """
792
- Add or merge container.
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
- container: Container to add or merge
867
+ tag: Tag to add or merge
796
868
 
797
869
  Returns:
798
- The resulting container (either new or merged)
870
+ The resulting tag (either new or merged)
799
871
  """
800
- if container.key in self._containers:
801
- r = self._merge_container(self._containers[container.key], container)
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 container
806
- self._containers[container.key] = container
807
- self._stats.register_container(container)
893
+ # Register new tag
894
+ self._tags[tag.key] = tag
895
+ self._stats.register_tag(tag)
808
896
  self._record_event(
809
- event_type="CONTAINER_CREATED",
810
- object_type="container",
811
- object_key=container.key,
897
+ event_type="TAG_CREATED",
898
+ object_type="tag",
899
+ object_key=tag.key,
812
900
  )
813
- return container
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._add_relationship_internal(source_obs, target_key, relationship_type, direction)
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._add_check_observable_link(check, link)
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._sync_check_links_for_observable(observable_key)
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 add_check_to_container(self, container_key: str, check_key: str) -> Container:
1032
+ def add_check_to_tag(self, tag_key: str, check_key: str) -> Tag:
947
1033
  """
948
- Add a check to a container.
1034
+ Add a check to a tag.
949
1035
 
950
1036
  Args:
951
- container_key: Key of the container
1037
+ tag_key: Key of the tag
952
1038
  check_key: Key of the check
953
1039
 
954
1040
  Returns:
955
- The container
1041
+ The tag
956
1042
 
957
1043
  Raises:
958
- KeyError: If the container or check does not exist
1044
+ KeyError: If the tag or check does not exist
959
1045
  """
960
- container = self._containers.get(container_key)
1046
+ tag = self._tags.get(tag_key)
961
1047
  check = self._checks.get(check_key)
962
1048
 
963
- if container is None:
964
- raise KeyError(f"container '{container_key}' not found in investigation.")
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 container and check:
969
- self._container_add_check(container, check)
1054
+ if tag and check:
1055
+ self._link_check_to_tag(tag, check)
970
1056
  self._record_event(
971
- event_type="CONTAINER_CHECK_ADDED",
972
- object_type="container",
973
- object_key=container.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 container
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 add_sub_container(self, parent_key: str, child_key: str) -> Container:
1081
+ def get_tag_children(self, tag_name: str) -> list[Tag]:
980
1082
  """
981
- Add a sub-container to a container.
1083
+ Get direct child tags of a tag.
982
1084
 
983
1085
  Args:
984
- parent_key: Key of the parent container
985
- child_key: Key of the child container
1086
+ tag_name: Name of the parent tag
986
1087
 
987
1088
  Returns:
988
- The parent container
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
- Raises:
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
- parent = self._containers.get(parent_key)
994
- child = self._containers.get(child_key)
1095
+ Get all descendant tags of a tag.
995
1096
 
996
- if parent is None:
997
- raise KeyError(f"container '{parent_key}' not found in investigation.")
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
- if parent and child:
1002
- self._container_add_sub_container(parent, child)
1003
- self._record_event(
1004
- event_type="CONTAINER_SUBCONTAINER_ADDED",
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
- return parent
1105
+ def get_tag_ancestors(self, tag_name: str) -> list[Tag]:
1106
+ """
1107
+ Get all ancestor tags of a tag.
1011
1108
 
1012
- # Query methods
1109
+ Args:
1110
+ tag_name: Name of the descendant tag
1013
1111
 
1014
- def get_observable(self, key: str) -> Observable | None:
1015
- """Get observable by full key string."""
1016
- return self._observables.get(key)
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
- def get_check(self, key: str) -> Check | None:
1019
- """Get check by full key string."""
1020
- return self._checks.get(key)
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
- def get_container(self, key: str) -> Container | None:
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", "container"],
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
- "container": self._containers,
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 get_all_containers(self) -> dict[str, Container]:
1156
- """Get all containers."""
1157
- return self._containers.copy()
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._add_relationship_internal(self._root_observable, best_node, RelationshipType.RELATED_TO)
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 _snapshot_container(container: Container) -> dict[str, Any]:
1508
+ def _snapshot_tag(tag: Tag) -> dict[str, Any]:
1401
1509
  return {
1402
- "description": container.description,
1403
- "checks": sorted(check.key for check in container.checks),
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
- incoming_containers,
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._add_relationship_internal(source_obs, rel.target_key, rel.relationship_type, rel.direction)
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 containers
1528
- for container in incoming_containers.values():
1529
- existing_container = self._containers.get(container.key)
1530
- before = _snapshot_container(existing_container) if existing_container else None
1531
- self.add_container(container)
1532
- if existing_container:
1533
- after = _snapshot_container(existing_container)
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": "container",
1542
- "object_key": container.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._refresh_check_links()
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
- )