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/model.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Core data models for Cyvest investigation framework.
3
3
 
4
- Defines the base classes for Check, Observable, ThreatIntel, Enrichment, Container,
4
+ Defines the base classes for Check, Observable, ThreatIntel, Enrichment, Tag,
5
5
  and InvestigationWhitelist using Pydantic BaseModel.
6
6
  """
7
7
 
@@ -37,6 +37,18 @@ from cyvest.model_enums import (
37
37
  _DEFAULT_SCORE_PLACES = 2
38
38
 
39
39
 
40
+ class AliasDumpModel(BaseModel):
41
+ """Base model that defaults to by_alias=True for JSON-compatible serialization."""
42
+
43
+ def model_dump(self, *, by_alias: bool = True, **kwargs: Any) -> dict[str, Any]:
44
+ """Serialize to dict, defaulting to by_alias=True for JSON compatibility."""
45
+ return super().model_dump(by_alias=by_alias, **kwargs)
46
+
47
+ def model_dump_json(self, *, by_alias: bool = True, **kwargs: Any) -> str:
48
+ """Serialize to JSON string, defaulting to by_alias=True."""
49
+ return super().model_dump_json(by_alias=by_alias, **kwargs)
50
+
51
+
40
52
  def _format_score_decimal(value: Decimal | None, *, places: int = _DEFAULT_SCORE_PLACES) -> str:
41
53
  if value is None:
42
54
  return "-"
@@ -257,7 +269,7 @@ class ThreatIntel(BaseModel):
257
269
  return _format_score_decimal(self.score)
258
270
 
259
271
 
260
- class Observable(BaseModel):
272
+ class Observable(AliasDumpModel):
261
273
  """
262
274
  Represents a cyber observable (IP, URL, domain, hash, etc.).
263
275
 
@@ -391,8 +403,7 @@ class Check(BaseModel):
391
403
 
392
404
  model_config = ConfigDict(arbitrary_types_allowed=True)
393
405
 
394
- check_id: str = Field(...)
395
- scope: str = Field(...)
406
+ check_name: str = Field(...)
396
407
  description: str = Field(...)
397
408
  comment: str = Field(...)
398
409
  extra: dict[str, Any] = Field(...)
@@ -442,7 +453,7 @@ class Check(BaseModel):
442
453
  def generate_key(self) -> Self:
443
454
  """Generate key."""
444
455
  if not self.key:
445
- self.key = keys.generate_check_key(self.check_id, self.scope)
456
+ self.key = keys.generate_check_key(self.check_name)
446
457
  return self
447
458
 
448
459
  @field_serializer("score")
@@ -491,27 +502,27 @@ class Enrichment(BaseModel):
491
502
  return values
492
503
 
493
504
 
494
- class Container(BaseModel):
505
+ class Tag(BaseModel):
495
506
  """
496
- Groups checks and sub-containers for hierarchical organization.
507
+ Groups checks for categorical organization.
497
508
 
498
- Containers allow structuring the investigation into logical sections
499
- with aggregated scores and levels.
509
+ Tags allow structuring the investigation into logical sections
510
+ with aggregated scores and levels. Hierarchy is automatic based on
511
+ the ":" delimiter in tag names (e.g., "header:auth:dkim").
500
512
  """
501
513
 
502
514
  model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
503
515
 
504
- path: str
516
+ name: str
505
517
  description: str = ""
506
518
  checks: list[Check] = Field(...)
507
- sub_containers: dict[str, Container] = Field(...)
508
519
  key: str = Field(...)
509
520
 
510
521
  @model_validator(mode="after")
511
522
  def generate_key(self) -> Self:
512
523
  """Generate key."""
513
524
  if not self.key:
514
- self.key = keys.generate_container_key(self.path)
525
+ self.key = keys.generate_tag_key(self.name)
515
526
  return self
516
527
 
517
528
  @model_validator(mode="before")
@@ -521,63 +532,64 @@ class Container(BaseModel):
521
532
  return values
522
533
  if "checks" not in values:
523
534
  values["checks"] = []
524
- if "sub_containers" not in values:
525
- values["sub_containers"] = {}
526
535
  if "key" not in values:
527
536
  values["key"] = ""
528
537
  return values
529
538
 
539
+ @field_serializer("checks")
540
+ def serialize_checks(self, value: list[Check]) -> list[str]:
541
+ """Serialize checks as keys only."""
542
+ return [check.key for check in value]
543
+
530
544
  @computed_field(return_type=Decimal)
531
545
  @property
532
- def aggregated_score(self) -> Decimal:
533
- return self.get_aggregated_score()
546
+ def direct_score(self) -> Decimal:
547
+ """
548
+ Calculate the score from direct checks only (no hierarchy).
549
+
550
+ For hierarchical aggregation (including descendant tags), use
551
+ Investigation.get_tag_aggregated_score() or TagProxy.get_aggregated_score().
534
552
 
535
- @field_serializer("aggregated_score")
536
- def serialize_aggregated_score(self, v: Decimal) -> float:
553
+ Returns:
554
+ Total score from direct checks
555
+ """
556
+ return self.get_direct_score()
557
+
558
+ @field_serializer("direct_score")
559
+ def serialize_direct_score(self, v: Decimal) -> float:
537
560
  return float(v)
538
561
 
539
- def get_aggregated_score(self) -> Decimal:
562
+ def get_direct_score(self) -> Decimal:
540
563
  """
541
- Calculate the aggregated score from all checks and sub-containers.
564
+ Calculate the score from direct checks only.
542
565
 
543
566
  Returns:
544
- Total aggregated score
567
+ Total score from direct checks
545
568
  """
546
569
  total = Decimal("0")
547
- # Sum scores from direct checks
548
570
  for check in self.checks:
549
571
  total += check.score
550
- # Sum scores from sub-containers
551
- for sub in self.sub_containers.values():
552
- total += sub.get_aggregated_score()
553
572
  return total
554
573
 
555
574
  @computed_field(return_type=Level)
556
575
  @property
557
- def aggregated_level(self) -> Level:
576
+ def direct_level(self) -> Level:
558
577
  """
559
- Calculate the aggregated level from the aggregated score.
578
+ Calculate the level from direct checks only (no hierarchy).
579
+
580
+ For hierarchical aggregation (including descendant tags), use
581
+ Investigation.get_tag_aggregated_level() or TagProxy.get_aggregated_level().
560
582
 
561
583
  Returns:
562
- Level based on aggregated score
584
+ Level based on direct score
563
585
  """
564
- return self.get_aggregated_level()
565
-
566
- @field_serializer("checks")
567
- def serialize_checks(self, value: list[Check]) -> list[str]:
568
- """Serialize checks as keys only."""
569
- return [check.key for check in value]
570
-
571
- @field_serializer("sub_containers")
572
- def serialize_sub_containers(self, value: dict[str, Container]) -> dict[str, Container]:
573
- """Serialize sub-containers recursively."""
574
- return {key: sub.model_dump() for key, sub in value.items()}
586
+ return self.get_direct_level()
575
587
 
576
- def get_aggregated_level(self) -> Level:
588
+ def get_direct_level(self) -> Level:
577
589
  """
578
- Calculate the aggregated level from the aggregated score.
590
+ Calculate the level from direct score only.
579
591
 
580
592
  Returns:
581
- Level based on aggregated score
593
+ Level based on direct score
582
594
  """
583
- return get_level_from_score(self.get_aggregated_score())
595
+ return get_level_from_score(self.get_direct_score())
cyvest/model_schema.py CHANGED
@@ -19,12 +19,13 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field, field_seriali
19
19
 
20
20
  from cyvest.levels import Level
21
21
  from cyvest.model import (
22
+ AliasDumpModel,
22
23
  AuditEvent,
23
24
  Check,
24
- Container,
25
25
  Enrichment,
26
26
  InvestigationWhitelist,
27
27
  Observable,
28
+ Tag,
28
29
  ThreatIntel,
29
30
  _format_score_decimal,
30
31
  )
@@ -50,12 +51,11 @@ class StatisticsSchema(BaseModel):
50
51
  observables_by_type_and_level: dict[str, dict[str, Annotated[int, Field(ge=0)]]] = Field(default_factory=dict)
51
52
  total_checks: Annotated[int, Field(ge=0)]
52
53
  applied_checks: Annotated[int, Field(ge=0)]
53
- checks_by_scope: dict[str, list[str]] = Field(default_factory=dict)
54
54
  checks_by_level: dict[str, list[str]] = Field(default_factory=dict)
55
55
  total_threat_intel: Annotated[int, Field(ge=0)]
56
56
  threat_intel_by_source: dict[str, Annotated[int, Field(ge=0)]] = Field(default_factory=dict)
57
57
  threat_intel_by_level: dict[str, Annotated[int, Field(ge=0)]] = Field(default_factory=dict)
58
- total_containers: Annotated[int, Field(ge=0)]
58
+ total_tags: Annotated[int, Field(ge=0)]
59
59
 
60
60
 
61
61
  class DataExtractionSchema(BaseModel):
@@ -72,7 +72,7 @@ class DataExtractionSchema(BaseModel):
72
72
  )
73
73
 
74
74
 
75
- class InvestigationSchema(BaseModel):
75
+ class InvestigationSchema(AliasDumpModel):
76
76
  """
77
77
  Schema for a complete serialized investigation.
78
78
 
@@ -117,9 +117,9 @@ class InvestigationSchema(BaseModel):
117
117
  ...,
118
118
  description="Observables keyed by their unique key.",
119
119
  )
120
- checks: dict[str, list[Check]] = Field(
120
+ checks: dict[str, Check] = Field(
121
121
  ...,
122
- description="Checks organized by scope.",
122
+ description="Checks keyed by their unique key.",
123
123
  )
124
124
  threat_intels: dict[str, ThreatIntel] = Field(
125
125
  ...,
@@ -129,9 +129,9 @@ class InvestigationSchema(BaseModel):
129
129
  ...,
130
130
  description="Enrichment entries keyed by their unique key.",
131
131
  )
132
- containers: dict[str, Container] = Field(
132
+ tags: dict[str, Tag] = Field(
133
133
  ...,
134
- description="Containers keyed by their unique key.",
134
+ description="Tags keyed by their unique key.",
135
135
  )
136
136
  stats: StatisticsSchema = Field(description="Investigation statistics summary.")
137
137
  data_extraction: DataExtractionSchema = Field(description="Data extraction metadata.")
@@ -159,6 +159,6 @@ class InvestigationSchema(BaseModel):
159
159
  v.setdefault("checks", {})
160
160
  v.setdefault("threat_intels", {})
161
161
  v.setdefault("enrichments", {})
162
- v.setdefault("containers", {})
162
+ v.setdefault("tags", {})
163
163
 
164
164
  return v
cyvest/proxies.py CHANGED
@@ -18,12 +18,12 @@ from cyvest import keys
18
18
  from cyvest.levels import Level
19
19
  from cyvest.model import (
20
20
  Check,
21
- Container,
22
21
  Enrichment,
23
22
  Observable,
24
23
  ObservableLink,
25
24
  ObservableType,
26
25
  Relationship,
26
+ Tag,
27
27
  Taxonomy,
28
28
  ThreatIntel,
29
29
  )
@@ -282,12 +282,8 @@ class CheckProxy(_ReadOnlyProxy[Check]):
282
282
  return check
283
283
 
284
284
  @property
285
- def check_id(self) -> str:
286
- return self._read_attr("check_id")
287
-
288
- @property
289
- def scope(self) -> str:
290
- return self._read_attr("scope")
285
+ def check_name(self) -> str:
286
+ return self._read_attr("check_name")
291
287
 
292
288
  @property
293
289
  def description(self) -> str:
@@ -350,18 +346,23 @@ class CheckProxy(_ReadOnlyProxy[Check]):
350
346
  self._get_investigation().update_model_metadata("check", self.key, updates, dict_merge=dict_merge)
351
347
  return self
352
348
 
353
- def in_container(self, container: Container | ContainerProxy | str) -> CheckProxy:
354
- """Add this check to a container."""
355
- if isinstance(container, ContainerProxy):
356
- container_key = container.key
357
- elif isinstance(container, Container):
358
- container_key = container.key
359
- elif isinstance(container, str):
360
- container_key = container
361
- else:
362
- raise TypeError("Container must provide a key.")
363
-
364
- self._get_investigation().add_check_to_container(container_key, self.key)
349
+ def tagged(self, *tags: Tag | TagProxy | str) -> CheckProxy:
350
+ """Add this check to one or more tags (auto-creates tags from strings)."""
351
+ investigation = self._get_investigation()
352
+ for tag in tags:
353
+ if isinstance(tag, TagProxy):
354
+ tag_key = tag.key
355
+ elif isinstance(tag, Tag):
356
+ tag_key = tag.key
357
+ elif isinstance(tag, str):
358
+ # Auto-create tag if it doesn't exist
359
+ tag_key = keys.generate_tag_key(tag)
360
+ if investigation.get_tag(tag_key) is None:
361
+ investigation.add_tag(Tag(name=tag, checks=[], key=tag_key))
362
+ else:
363
+ raise TypeError("Tag must provide a key.")
364
+
365
+ investigation.add_check_to_tag(tag_key, self.key)
365
366
  return self
366
367
 
367
368
  def link_observable(
@@ -390,18 +391,18 @@ class CheckProxy(_ReadOnlyProxy[Check]):
390
391
  return self
391
392
 
392
393
 
393
- class ContainerProxy(_ReadOnlyProxy[Container]):
394
- """Read-only proxy over a container."""
394
+ class TagProxy(_ReadOnlyProxy[Tag]):
395
+ """Read-only proxy over a tag."""
395
396
 
396
397
  def _resolve(self):
397
- container = self._get_investigation().get_container(self.key)
398
- if container is None:
399
- raise ModelNotFoundError(f"Container '{self.key}' no longer exists in this investigation.")
400
- return container
398
+ tag = self._get_investigation().get_tag(self.key)
399
+ if tag is None:
400
+ raise ModelNotFoundError(f"Tag '{self.key}' no longer exists in this investigation.")
401
+ return tag
401
402
 
402
403
  @property
403
- def path(self) -> str:
404
- return self._read_attr("path")
404
+ def name(self) -> str:
405
+ return self._read_attr("name")
405
406
 
406
407
  @property
407
408
  def description(self) -> str:
@@ -411,20 +412,26 @@ class ContainerProxy(_ReadOnlyProxy[Container]):
411
412
  def checks(self) -> list[Check]:
412
413
  return self._read_attr("checks")
413
414
 
414
- @property
415
- def sub_containers(self) -> dict[str, Container]:
416
- return self._read_attr("sub_containers")
415
+ def get_direct_score(self):
416
+ """Return the direct score (checks in this tag only, no hierarchy)."""
417
+ return self._call_readonly("get_direct_score")
418
+
419
+ def get_direct_level(self):
420
+ """Return the direct level (from direct score only, no hierarchy)."""
421
+ return self._call_readonly("get_direct_level")
417
422
 
418
423
  def get_aggregated_score(self):
419
- """Return the aggregated score copy."""
420
- return self._call_readonly("get_aggregated_score")
424
+ """Return the aggregated score including all descendant tags."""
425
+ tag = self._resolve()
426
+ return self._get_investigation().get_tag_aggregated_score(tag.name)
421
427
 
422
428
  def get_aggregated_level(self):
423
- """Return the aggregated level copy."""
424
- return self._call_readonly("get_aggregated_level")
429
+ """Return the aggregated level including all descendant tags."""
430
+ tag = self._resolve()
431
+ return self._get_investigation().get_tag_aggregated_level(tag.name)
425
432
 
426
- def add_check(self, check: Check | CheckProxy | str) -> ContainerProxy:
427
- """Add a check to this container."""
433
+ def add_check(self, check: Check | CheckProxy | str) -> TagProxy:
434
+ """Add a check to this tag."""
428
435
  if isinstance(check, CheckProxy):
429
436
  check_key = check.key
430
437
  elif isinstance(check, Check):
@@ -434,19 +441,10 @@ class ContainerProxy(_ReadOnlyProxy[Container]):
434
441
  else:
435
442
  raise TypeError("Check must provide a key.")
436
443
 
437
- self._get_investigation().add_check_to_container(self.key, check_key)
444
+ self._get_investigation().add_check_to_tag(self.key, check_key)
438
445
  return self
439
446
 
440
- def sub_container(self, path: str, description: str = "") -> ContainerProxy:
441
- """Create a sub-container nested beneath this container."""
442
- parent = self._resolve()
443
- full_path = f"{parent.path}/{path}"
444
- sub = Container(path=full_path, description=description)
445
- sub = self._get_investigation().add_container(sub)
446
- self._get_investigation().add_sub_container(self.key, sub.key)
447
- return ContainerProxy(self._get_investigation(), sub.key)
448
-
449
- def __enter__(self) -> ContainerProxy:
447
+ def __enter__(self) -> TagProxy:
450
448
  """Context manager entry returning self."""
451
449
  return self
452
450
 
@@ -454,11 +452,11 @@ class ContainerProxy(_ReadOnlyProxy[Container]):
454
452
  """Context manager exit (no-op)."""
455
453
  return None
456
454
 
457
- def update_metadata(self, *, description: str | None = None) -> ContainerProxy:
458
- """Update mutable metadata on the container."""
455
+ def update_metadata(self, *, description: str | None = None) -> TagProxy:
456
+ """Update mutable metadata on the tag."""
459
457
  if description is None:
460
458
  return self
461
- self._get_investigation().update_model_metadata("container", self.key, {"description": description})
459
+ self._get_investigation().update_model_metadata("tag", self.key, {"description": description})
462
460
  return self
463
461
 
464
462
 
cyvest/shared.py CHANGED
@@ -311,23 +311,23 @@ class SharedInvestigationContext:
311
311
  except Exception as e:
312
312
  raise ValueError(f"Failed to generate observable key for type='{obs_type}', value='{value}': {e}") from e
313
313
 
314
- def check_get(self, check_id: str, scope: str) -> Check | None:
315
- key = self._check_key(check_id, scope)
314
+ def check_get(self, check_name: str) -> Check | None:
315
+ key = self._check_key(check_name)
316
316
  return self._lock.run(self._get_check_by_key_unlocked, key)
317
317
 
318
- async def check_aget(self, check_id: str, scope: str) -> Check | None:
319
- key = self._check_key(check_id, scope)
318
+ async def check_aget(self, check_name: str) -> Check | None:
319
+ key = self._check_key(check_name)
320
320
  return await self._lock.arun(self._get_check_by_key_unlocked, key)
321
321
 
322
322
  def _get_check_by_key_unlocked(self, key: str) -> Check | None:
323
323
  check = self._check_registry.get(key)
324
324
  return check.model_copy(deep=True) if check else None
325
325
 
326
- def _check_key(self, check_id: str, scope: str) -> str:
326
+ def _check_key(self, check_name: str) -> str:
327
327
  try:
328
- return keys.generate_check_key(check_id, scope)
328
+ return keys.generate_check_key(check_name)
329
329
  except Exception as e:
330
- raise ValueError(f"Failed to generate check key for check_id='{check_id}', scope='{scope}': {e}") from e
330
+ raise ValueError(f"Failed to generate check key for check_name='{check_name}': {e}") from e
331
331
 
332
332
  def enrichment_get(self, name: str, context: str = "") -> Enrichment | None:
333
333
  key = self._enrichment_key(name, context)
@@ -393,39 +393,39 @@ class SharedInvestigationContext:
393
393
 
394
394
  def io_to_markdown(
395
395
  self,
396
- include_containers: bool = False,
396
+ include_tags: bool = False,
397
397
  include_enrichments: bool = False,
398
398
  include_observables: bool = True,
399
399
  ) -> str:
400
400
  return self._lock.run(
401
401
  self._io_to_markdown_unlocked,
402
- include_containers,
402
+ include_tags,
403
403
  include_enrichments,
404
404
  include_observables,
405
405
  )
406
406
 
407
407
  async def aio_to_markdown(
408
408
  self,
409
- include_containers: bool = False,
409
+ include_tags: bool = False,
410
410
  include_enrichments: bool = False,
411
411
  include_observables: bool = True,
412
412
  ) -> str:
413
413
  return await self._lock.arun(
414
414
  self._io_to_markdown_unlocked,
415
- include_containers,
415
+ include_tags,
416
416
  include_enrichments,
417
417
  include_observables,
418
418
  )
419
419
 
420
420
  def _io_to_markdown_unlocked(
421
421
  self,
422
- include_containers: bool,
422
+ include_tags: bool,
423
423
  include_enrichments: bool,
424
424
  include_observables: bool,
425
425
  ) -> str:
426
426
  return generate_markdown_report(
427
427
  self._main_investigation,
428
- include_containers,
428
+ include_tags,
429
429
  include_enrichments,
430
430
  include_observables,
431
431
  )
@@ -433,14 +433,14 @@ class SharedInvestigationContext:
433
433
  def io_save_markdown(
434
434
  self,
435
435
  filepath: str | Path,
436
- include_containers: bool = False,
436
+ include_tags: bool = False,
437
437
  include_enrichments: bool = False,
438
438
  include_observables: bool = True,
439
439
  ) -> str:
440
440
  return self._lock.run(
441
441
  self._io_save_markdown_unlocked,
442
442
  filepath,
443
- include_containers,
443
+ include_tags,
444
444
  include_enrichments,
445
445
  include_observables,
446
446
  )
@@ -448,14 +448,14 @@ class SharedInvestigationContext:
448
448
  async def aio_save_markdown(
449
449
  self,
450
450
  filepath: str | Path,
451
- include_containers: bool = False,
451
+ include_tags: bool = False,
452
452
  include_enrichments: bool = False,
453
453
  include_observables: bool = True,
454
454
  ) -> str:
455
455
  return await self._lock.arun(
456
456
  self._io_save_markdown_unlocked,
457
457
  filepath,
458
- include_containers,
458
+ include_tags,
459
459
  include_enrichments,
460
460
  include_observables,
461
461
  )
@@ -463,14 +463,14 @@ class SharedInvestigationContext:
463
463
  def _io_save_markdown_unlocked(
464
464
  self,
465
465
  filepath: str | Path,
466
- include_containers: bool,
466
+ include_tags: bool,
467
467
  include_enrichments: bool,
468
468
  include_observables: bool,
469
469
  ) -> str:
470
470
  save_investigation_markdown(
471
471
  self._main_investigation,
472
472
  filepath,
473
- include_containers,
473
+ include_tags,
474
474
  include_enrichments,
475
475
  include_observables,
476
476
  )
cyvest/stats.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from collections import defaultdict
11
11
 
12
12
  from cyvest.levels import Level
13
- from cyvest.model import Check, Container, Observable, ThreatIntel
13
+ from cyvest.model import Check, Observable, Tag, ThreatIntel
14
14
  from cyvest.model_schema import StatisticsSchema
15
15
 
16
16
 
@@ -27,7 +27,7 @@ class InvestigationStats:
27
27
  self._observables: dict[str, Observable] = {}
28
28
  self._checks: dict[str, Check] = {}
29
29
  self._threat_intels: dict[str, ThreatIntel] = {}
30
- self._containers: dict[str, Container] = {}
30
+ self._tags: dict[str, Tag] = {}
31
31
 
32
32
  def register_observable(self, observable: Observable) -> None:
33
33
  """
@@ -56,14 +56,14 @@ class InvestigationStats:
56
56
  """
57
57
  self._threat_intels[ti.key] = ti
58
58
 
59
- def register_container(self, container: Container) -> None:
59
+ def register_tag(self, tag: Tag) -> None:
60
60
  """
61
- Register a container for statistics tracking.
61
+ Register a tag for statistics tracking.
62
62
 
63
63
  Args:
64
- container: Container to track
64
+ tag: Tag to track
65
65
  """
66
- self._containers[container.key] = container
66
+ self._tags[tag.key] = tag
67
67
 
68
68
  def get_observable_count_by_type(self) -> dict[str, int]:
69
69
  """
@@ -142,30 +142,6 @@ class InvestigationStats:
142
142
  """
143
143
  return sum(1 for obs in self._observables.values() if obs.whitelisted)
144
144
 
145
- def get_check_count_by_scope(self) -> dict[str, int]:
146
- """
147
- Get count of checks by scope.
148
-
149
- Returns:
150
- Dictionary mapping scope to count
151
- """
152
- counts: dict[str, int] = defaultdict(int)
153
- for check in self._checks.values():
154
- counts[check.scope] += 1
155
- return dict(counts)
156
-
157
- def get_check_keys_by_scope(self) -> dict[str, list[str]]:
158
- """
159
- Get check keys grouped by scope.
160
-
161
- Returns:
162
- Dictionary mapping scope to list of check keys
163
- """
164
- keys: dict[str, list[str]] = defaultdict(list)
165
- for check in self._checks.values():
166
- keys[check.scope].append(check.key)
167
- return dict(keys)
168
-
169
145
  def get_check_count_by_level(self) -> dict[Level, int]:
170
146
  """
171
147
  Get count of checks by level.
@@ -241,14 +217,14 @@ class InvestigationStats:
241
217
  counts[ti.level] += 1
242
218
  return dict(counts)
243
219
 
244
- def get_container_count(self) -> int:
220
+ def get_tag_count(self) -> int:
245
221
  """
246
- Get total number of containers.
222
+ Get total number of tags.
247
223
 
248
224
  Returns:
249
- Total container count
225
+ Total tag count
250
226
  """
251
- return len(self._containers)
227
+ return len(self._tags)
252
228
 
253
229
  def get_checks_by_level(self, level: Level) -> list[Check]:
254
230
  """
@@ -307,10 +283,9 @@ class InvestigationStats:
307
283
  },
308
284
  total_checks=self.get_total_check_count(),
309
285
  applied_checks=self.get_applied_check_count(),
310
- checks_by_scope=self.get_check_keys_by_scope(),
311
286
  checks_by_level={str(k): v for k, v in self.get_check_keys_by_level().items()},
312
287
  total_threat_intel=self.get_threat_intel_count(),
313
288
  threat_intel_by_source=self.get_threat_intel_count_by_source(),
314
289
  threat_intel_by_level={str(k): v for k, v in self.get_threat_intel_count_by_level().items()},
315
- total_containers=self.get_container_count(),
290
+ total_tags=self.get_tag_count(),
316
291
  )