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/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,
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
505
|
+
class Tag(BaseModel):
|
|
495
506
|
"""
|
|
496
|
-
Groups checks
|
|
507
|
+
Groups checks for categorical organization.
|
|
497
508
|
|
|
498
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
|
562
|
+
def get_direct_score(self) -> Decimal:
|
|
540
563
|
"""
|
|
541
|
-
Calculate the
|
|
564
|
+
Calculate the score from direct checks only.
|
|
542
565
|
|
|
543
566
|
Returns:
|
|
544
|
-
Total
|
|
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
|
|
576
|
+
def direct_level(self) -> Level:
|
|
558
577
|
"""
|
|
559
|
-
Calculate the
|
|
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
|
|
584
|
+
Level based on direct score
|
|
563
585
|
"""
|
|
564
|
-
return self.
|
|
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
|
|
588
|
+
def get_direct_level(self) -> Level:
|
|
577
589
|
"""
|
|
578
|
-
Calculate the
|
|
590
|
+
Calculate the level from direct score only.
|
|
579
591
|
|
|
580
592
|
Returns:
|
|
581
|
-
Level based on
|
|
593
|
+
Level based on direct score
|
|
582
594
|
"""
|
|
583
|
-
return get_level_from_score(self.
|
|
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
|
-
|
|
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(
|
|
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,
|
|
120
|
+
checks: dict[str, Check] = Field(
|
|
121
121
|
...,
|
|
122
|
-
description="Checks
|
|
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
|
-
|
|
132
|
+
tags: dict[str, Tag] = Field(
|
|
133
133
|
...,
|
|
134
|
-
description="
|
|
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("
|
|
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
|
|
286
|
-
return self._read_attr("
|
|
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
|
|
354
|
-
"""Add this check to
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
394
|
-
"""Read-only proxy over a
|
|
394
|
+
class TagProxy(_ReadOnlyProxy[Tag]):
|
|
395
|
+
"""Read-only proxy over a tag."""
|
|
395
396
|
|
|
396
397
|
def _resolve(self):
|
|
397
|
-
|
|
398
|
-
if
|
|
399
|
-
raise ModelNotFoundError(f"
|
|
400
|
-
return
|
|
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
|
|
404
|
-
return self._read_attr("
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
return self.
|
|
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
|
|
420
|
-
|
|
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
|
|
424
|
-
|
|
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) ->
|
|
427
|
-
"""Add a check to this
|
|
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().
|
|
444
|
+
self._get_investigation().add_check_to_tag(self.key, check_key)
|
|
438
445
|
return self
|
|
439
446
|
|
|
440
|
-
def
|
|
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) ->
|
|
458
|
-
"""Update mutable metadata on the
|
|
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("
|
|
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,
|
|
315
|
-
key = self._check_key(
|
|
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,
|
|
319
|
-
key = self._check_key(
|
|
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,
|
|
326
|
+
def _check_key(self, check_name: str) -> str:
|
|
327
327
|
try:
|
|
328
|
-
return keys.generate_check_key(
|
|
328
|
+
return keys.generate_check_key(check_name)
|
|
329
329
|
except Exception as e:
|
|
330
|
-
raise ValueError(f"Failed to generate check key for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
59
|
+
def register_tag(self, tag: Tag) -> None:
|
|
60
60
|
"""
|
|
61
|
-
Register a
|
|
61
|
+
Register a tag for statistics tracking.
|
|
62
62
|
|
|
63
63
|
Args:
|
|
64
|
-
|
|
64
|
+
tag: Tag to track
|
|
65
65
|
"""
|
|
66
|
-
self.
|
|
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
|
|
220
|
+
def get_tag_count(self) -> int:
|
|
245
221
|
"""
|
|
246
|
-
Get total number of
|
|
222
|
+
Get total number of tags.
|
|
247
223
|
|
|
248
224
|
Returns:
|
|
249
|
-
Total
|
|
225
|
+
Total tag count
|
|
250
226
|
"""
|
|
251
|
-
return len(self.
|
|
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
|
-
|
|
290
|
+
total_tags=self.get_tag_count(),
|
|
316
291
|
)
|