cyvest 2.0.0__tar.gz → 3.1.0__tar.gz
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.
- {cyvest-2.0.0 → cyvest-3.1.0}/PKG-INFO +7 -4
- {cyvest-2.0.0 → cyvest-3.1.0}/README.md +4 -3
- {cyvest-2.0.0 → cyvest-3.1.0}/pyproject.toml +3 -1
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/__init__.py +3 -3
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/cli.py +5 -5
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/cyvest.py +57 -52
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/investigation.py +37 -57
- cyvest-3.1.0/src/cyvest/io_rich.py +480 -0
- cyvest-3.1.0/src/cyvest/io_schema.py +35 -0
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/io_serialization.py +185 -249
- cyvest-3.1.0/src/cyvest/level_score_rules.py +68 -0
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/levels.py +39 -13
- cyvest-3.1.0/src/cyvest/model.py +709 -0
- cyvest-3.1.0/src/cyvest/model_enums.py +79 -0
- cyvest-3.1.0/src/cyvest/model_schema.py +175 -0
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/proxies.py +13 -107
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/score.py +45 -40
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/stats.py +24 -21
- cyvest-2.0.0/src/cyvest/io_rich.py +0 -339
- cyvest-2.0.0/src/cyvest/io_schema.py +0 -393
- cyvest-2.0.0/src/cyvest/model.py +0 -537
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/io_visualization.py +0 -0
- {cyvest-2.0.0 → cyvest-3.1.0}/src/cyvest/keys.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cyvest
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Cybersecurity investigation model
|
|
5
5
|
Keywords: cybersecurity,investigation,threat-intel,security-analysis
|
|
6
6
|
Author: PakitoSec
|
|
@@ -16,7 +16,9 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Topic :: Security
|
|
17
17
|
Requires-Dist: click>=8
|
|
18
18
|
Requires-Dist: logurich[click]>=0.1
|
|
19
|
+
Requires-Dist: pydantic>=2.12.5
|
|
19
20
|
Requires-Dist: rich>=13
|
|
21
|
+
Requires-Dist: typing-extensions>=4.15
|
|
20
22
|
Requires-Dist: pyvis>=0.3.2 ; extra == 'visualization'
|
|
21
23
|
Requires-Python: >=3.10
|
|
22
24
|
Project-URL: Homepage, https://github.com/PakitoSec/cyvest
|
|
@@ -298,7 +300,8 @@ SAFE checks:
|
|
|
298
300
|
|
|
299
301
|
**Root Observable Barrier:**
|
|
300
302
|
|
|
301
|
-
The root observable (the investigation's entry point with `value="
|
|
303
|
+
The root observable (the investigation's entry point with `value="root"`) acts as a special barrier to prevent cross-contamination:
|
|
304
|
+
Its key is derived from type + value (e.g. `obs:file:root` or `obs:artifact:root`).
|
|
302
305
|
|
|
303
306
|
**Barrier as Child** - When root appears as a child of other observables, it is **skipped** in their score calculations.
|
|
304
307
|
|
|
@@ -353,8 +356,8 @@ cyvest merge inv1.json inv2.json -o merged.json -f rich --stats
|
|
|
353
356
|
# Generate an interactive visualization (requires visualization extra)
|
|
354
357
|
cyvest visualize investigation.json --min-level SUSPICIOUS --group-by-type
|
|
355
358
|
|
|
356
|
-
# Output the JSON Schema describing serialized investigations
|
|
357
|
-
cyvest schema
|
|
359
|
+
# Output the JSON Schema describing serialized investigations and generate types
|
|
360
|
+
uv run cyvest schema -o ./schema/cyvest.schema.json && pnpm -C js/packages/cyvest-js run generate:types
|
|
358
361
|
```
|
|
359
362
|
|
|
360
363
|
## Development
|
|
@@ -271,7 +271,8 @@ SAFE checks:
|
|
|
271
271
|
|
|
272
272
|
**Root Observable Barrier:**
|
|
273
273
|
|
|
274
|
-
The root observable (the investigation's entry point with `value="
|
|
274
|
+
The root observable (the investigation's entry point with `value="root"`) acts as a special barrier to prevent cross-contamination:
|
|
275
|
+
Its key is derived from type + value (e.g. `obs:file:root` or `obs:artifact:root`).
|
|
275
276
|
|
|
276
277
|
**Barrier as Child** - When root appears as a child of other observables, it is **skipped** in their score calculations.
|
|
277
278
|
|
|
@@ -326,8 +327,8 @@ cyvest merge inv1.json inv2.json -o merged.json -f rich --stats
|
|
|
326
327
|
# Generate an interactive visualization (requires visualization extra)
|
|
327
328
|
cyvest visualize investigation.json --min-level SUSPICIOUS --group-by-type
|
|
328
329
|
|
|
329
|
-
# Output the JSON Schema describing serialized investigations
|
|
330
|
-
cyvest schema
|
|
330
|
+
# Output the JSON Schema describing serialized investigations and generate types
|
|
331
|
+
uv run cyvest schema -o ./schema/cyvest.schema.json && pnpm -C js/packages/cyvest-js run generate:types
|
|
331
332
|
```
|
|
332
333
|
|
|
333
334
|
## Development
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cyvest"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.1.0"
|
|
4
4
|
description = "Cybersecurity investigation model"
|
|
5
5
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -11,7 +11,9 @@ authors = [
|
|
|
11
11
|
dependencies = [
|
|
12
12
|
"click>=8",
|
|
13
13
|
"logurich[click]>=0.1",
|
|
14
|
+
"pydantic>=2.12.5",
|
|
14
15
|
"rich>=13",
|
|
16
|
+
"typing-extensions>=4.15",
|
|
15
17
|
]
|
|
16
18
|
keywords = ["cybersecurity", "investigation", "threat-intel", "security-analysis"]
|
|
17
19
|
classifiers = [
|
|
@@ -8,12 +8,12 @@ programmatically with automatic scoring, level calculation, and rich reporting c
|
|
|
8
8
|
from logurich import logger
|
|
9
9
|
|
|
10
10
|
from cyvest.cyvest import Cyvest
|
|
11
|
-
from cyvest.investigation import InvestigationWhitelist
|
|
12
11
|
from cyvest.levels import Level
|
|
13
|
-
from cyvest.model import
|
|
12
|
+
from cyvest.model import InvestigationWhitelist
|
|
13
|
+
from cyvest.model_enums import CheckScorePolicy, ObservableType, RelationshipDirection, RelationshipType
|
|
14
14
|
from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
|
|
15
15
|
|
|
16
|
-
__version__ = "
|
|
16
|
+
__version__ = "3.1.0"
|
|
17
17
|
|
|
18
18
|
logger.disable("cyvest")
|
|
19
19
|
|
|
@@ -166,10 +166,10 @@ def merge(inputs: tuple[Path, ...], output: Path, output_format: str, stats: boo
|
|
|
166
166
|
if stats:
|
|
167
167
|
logger.info("[bold]Merged Investigation Statistics:[/bold]")
|
|
168
168
|
investigation_stats = main_investigation.get_statistics()
|
|
169
|
-
logger.info(f" Total Observables: {investigation_stats.
|
|
170
|
-
logger.info(f" Total Checks: {investigation_stats.
|
|
171
|
-
logger.info(f" Total Threat Intel: {investigation_stats.
|
|
172
|
-
logger.info(f" Total Containers: {investigation_stats.
|
|
169
|
+
logger.info(f" Total Observables: {investigation_stats.total_observables}")
|
|
170
|
+
logger.info(f" Total Checks: {investigation_stats.total_checks}")
|
|
171
|
+
logger.info(f" Total Threat Intel: {investigation_stats.total_threat_intel}")
|
|
172
|
+
logger.info(f" Total Containers: {investigation_stats.total_containers}")
|
|
173
173
|
logger.info(f" Global Score: {main_investigation.get_global_score()}")
|
|
174
174
|
logger.info(f" Global Level: {main_investigation.get_global_level()}\n")
|
|
175
175
|
|
|
@@ -236,7 +236,7 @@ def schema_cmd(output: Path | None) -> None:
|
|
|
236
236
|
if output:
|
|
237
237
|
output_path = output.resolve()
|
|
238
238
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
-
output_path.write_text(json.dumps(schema, indent=2), encoding="utf-8")
|
|
239
|
+
output_path.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8")
|
|
240
240
|
logger.info(f"[green]Schema written to: {output_path}[/green]")
|
|
241
241
|
return
|
|
242
242
|
|
|
@@ -26,10 +26,11 @@ from cyvest.io_serialization import (
|
|
|
26
26
|
save_investigation_markdown,
|
|
27
27
|
serialize_investigation,
|
|
28
28
|
)
|
|
29
|
-
from cyvest.levels import Level
|
|
29
|
+
from cyvest.levels import Level
|
|
30
30
|
from cyvest.model import Check, CheckScorePolicy, Container, Enrichment, Observable, ThreatIntel
|
|
31
|
+
from cyvest.model_schema import InvestigationSchema, StatisticsSchema
|
|
31
32
|
from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
|
|
32
|
-
from cyvest.score import ScoreMode
|
|
33
|
+
from cyvest.score import ScoreMode
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
class Cyvest:
|
|
@@ -54,7 +55,7 @@ class Cyvest:
|
|
|
54
55
|
root_type: Type of root observable ("file" or "artifact")
|
|
55
56
|
score_mode: Score calculation mode (MAX or SUM)
|
|
56
57
|
"""
|
|
57
|
-
normalized_score_mode =
|
|
58
|
+
normalized_score_mode = ScoreMode.normalize(score_mode)
|
|
58
59
|
self._investigation = Investigation(data, root_type=root_type, score_mode=normalized_score_mode)
|
|
59
60
|
|
|
60
61
|
def __enter__(self) -> Cyvest:
|
|
@@ -214,18 +215,19 @@ class Cyvest:
|
|
|
214
215
|
Returns:
|
|
215
216
|
The created or existing observable
|
|
216
217
|
"""
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
score=Decimal(str(score))
|
|
227
|
-
|
|
228
|
-
|
|
218
|
+
obs_kwargs: dict[str, Any] = {
|
|
219
|
+
"obs_type": obs_type,
|
|
220
|
+
"value": value,
|
|
221
|
+
"internal": internal,
|
|
222
|
+
"whitelisted": whitelisted,
|
|
223
|
+
"comment": comment,
|
|
224
|
+
"extra": extra or {},
|
|
225
|
+
}
|
|
226
|
+
if score is not None:
|
|
227
|
+
obs_kwargs["score"] = Decimal(str(score))
|
|
228
|
+
if level is not None:
|
|
229
|
+
obs_kwargs["level"] = level
|
|
230
|
+
obs = Observable(**obs_kwargs)
|
|
229
231
|
# Unwrap tuple - facade returns only Observable, discards deferred relationships
|
|
230
232
|
obs_result, _ = self._investigation.add_observable(obs)
|
|
231
233
|
return self._observable_proxy(obs_result)
|
|
@@ -304,17 +306,17 @@ class Cyvest:
|
|
|
304
306
|
if not observable:
|
|
305
307
|
return None
|
|
306
308
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
)
|
|
309
|
+
ti_kwargs: dict[str, Any] = {
|
|
310
|
+
"source": source,
|
|
311
|
+
"observable_key": observable_key,
|
|
312
|
+
"comment": comment,
|
|
313
|
+
"extra": extra or {},
|
|
314
|
+
"score": Decimal(str(score)),
|
|
315
|
+
"taxonomies": taxonomies or [],
|
|
316
|
+
}
|
|
317
|
+
if level is not None:
|
|
318
|
+
ti_kwargs["level"] = level
|
|
319
|
+
ti = ThreatIntel(**ti_kwargs)
|
|
318
320
|
result = self._investigation.add_threat_intel(ti, observable)
|
|
319
321
|
return self._threat_intel_proxy(result)
|
|
320
322
|
|
|
@@ -332,7 +334,7 @@ class Cyvest:
|
|
|
332
334
|
observable = self._investigation.get_observable(observable_key)
|
|
333
335
|
if not observable:
|
|
334
336
|
return None
|
|
335
|
-
observable.set_level(
|
|
337
|
+
observable.set_level(level)
|
|
336
338
|
return self._observable_proxy(observable)
|
|
337
339
|
|
|
338
340
|
def observable_finalize_relationships(self) -> None:
|
|
@@ -372,19 +374,20 @@ class Cyvest:
|
|
|
372
374
|
Returns:
|
|
373
375
|
The created check
|
|
374
376
|
"""
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
level=
|
|
386
|
-
|
|
387
|
-
|
|
377
|
+
check_kwargs: dict[str, Any] = {
|
|
378
|
+
"check_id": check_id,
|
|
379
|
+
"scope": scope,
|
|
380
|
+
"description": description,
|
|
381
|
+
"comment": comment,
|
|
382
|
+
"extra": extra or {},
|
|
383
|
+
}
|
|
384
|
+
if score is not None:
|
|
385
|
+
check_kwargs["score"] = Decimal(str(score))
|
|
386
|
+
if level is not None:
|
|
387
|
+
check_kwargs["level"] = level
|
|
388
|
+
if score_policy is not None:
|
|
389
|
+
check_kwargs["score_policy"] = score_policy
|
|
390
|
+
check = Check(**check_kwargs)
|
|
388
391
|
return self._check_proxy(self._investigation.add_check(check))
|
|
389
392
|
|
|
390
393
|
def check_get(self, key: str) -> CheckProxy | None:
|
|
@@ -532,12 +535,12 @@ class Cyvest:
|
|
|
532
535
|
"""
|
|
533
536
|
return self._investigation.get_global_level()
|
|
534
537
|
|
|
535
|
-
def get_statistics(self) ->
|
|
538
|
+
def get_statistics(self) -> StatisticsSchema:
|
|
536
539
|
"""
|
|
537
540
|
Get comprehensive investigation statistics.
|
|
538
541
|
|
|
539
542
|
Returns:
|
|
540
|
-
Statistics
|
|
543
|
+
Statistics schema with typed fields
|
|
541
544
|
"""
|
|
542
545
|
return self._investigation.get_statistics()
|
|
543
546
|
|
|
@@ -626,18 +629,18 @@ class Cyvest:
|
|
|
626
629
|
"""
|
|
627
630
|
return generate_markdown_report(self, include_containers, include_enrichments, include_observables)
|
|
628
631
|
|
|
629
|
-
def io_to_dict(self) ->
|
|
632
|
+
def io_to_dict(self) -> InvestigationSchema:
|
|
630
633
|
"""
|
|
631
|
-
Serialize the investigation to
|
|
634
|
+
Serialize the investigation to an InvestigationSchema.
|
|
632
635
|
|
|
633
636
|
Returns:
|
|
634
|
-
|
|
637
|
+
InvestigationSchema instance (use .model_dump() for dict)
|
|
635
638
|
|
|
636
639
|
Examples:
|
|
637
640
|
>>> cv = Cyvest()
|
|
638
|
-
>>>
|
|
639
|
-
>>> print(
|
|
640
|
-
|
|
641
|
+
>>> schema = cv.io_to_dict()
|
|
642
|
+
>>> print(schema.score, schema.level)
|
|
643
|
+
>>> dict_data = schema.model_dump(by_alias=True)
|
|
641
644
|
"""
|
|
642
645
|
return serialize_investigation(self)
|
|
643
646
|
|
|
@@ -691,13 +694,17 @@ class Cyvest:
|
|
|
691
694
|
}
|
|
692
695
|
|
|
693
696
|
def display_summary(
|
|
694
|
-
self,
|
|
697
|
+
self,
|
|
698
|
+
show_graph: bool = True,
|
|
699
|
+
exclude_levels: Level | str | Iterable[Level | str] = Level.NONE,
|
|
700
|
+
show_score_history: bool = False,
|
|
695
701
|
) -> None:
|
|
696
702
|
display_summary(
|
|
697
703
|
self,
|
|
698
704
|
lambda renderables: logger.rich("INFO", renderables),
|
|
699
705
|
show_graph=show_graph,
|
|
700
706
|
exclude_levels=exclude_levels,
|
|
707
|
+
show_score_history=show_score_history,
|
|
701
708
|
)
|
|
702
709
|
|
|
703
710
|
def display_statistics(self) -> None:
|
|
@@ -748,13 +755,11 @@ class Cyvest:
|
|
|
748
755
|
if observable_types is not None:
|
|
749
756
|
obs_types_enum = [ObservableType(t) for t in observable_types]
|
|
750
757
|
|
|
751
|
-
normalized_min_level = normalize_level(min_level) if min_level is not None else None
|
|
752
|
-
|
|
753
758
|
return generate_network_graph(
|
|
754
759
|
self,
|
|
755
760
|
output_dir=output_dir,
|
|
756
761
|
open_browser=open_browser,
|
|
757
|
-
min_level=
|
|
762
|
+
min_level=min_level,
|
|
758
763
|
observable_types=obs_types_enum,
|
|
759
764
|
physics=physics,
|
|
760
765
|
group_by_type=group_by_type,
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import threading
|
|
11
11
|
from copy import deepcopy
|
|
12
|
-
from
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
13
|
from decimal import Decimal
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
@@ -17,13 +17,24 @@ from typing import TYPE_CHECKING, Any, Literal, overload
|
|
|
17
17
|
from logurich import logger
|
|
18
18
|
|
|
19
19
|
from cyvest import keys
|
|
20
|
-
from cyvest.
|
|
21
|
-
from cyvest.
|
|
22
|
-
from cyvest.
|
|
20
|
+
from cyvest.level_score_rules import recalculate_level_for_score
|
|
21
|
+
from cyvest.levels import Level, normalize_level
|
|
22
|
+
from cyvest.model import (
|
|
23
|
+
Check,
|
|
24
|
+
CheckScorePolicy,
|
|
25
|
+
Container,
|
|
26
|
+
Enrichment,
|
|
27
|
+
InvestigationWhitelist,
|
|
28
|
+
Observable,
|
|
29
|
+
ObservableType,
|
|
30
|
+
ThreatIntel,
|
|
31
|
+
)
|
|
32
|
+
from cyvest.score import ScoreEngine, ScoreMode
|
|
23
33
|
from cyvest.stats import InvestigationStats
|
|
24
34
|
|
|
25
35
|
if TYPE_CHECKING:
|
|
26
36
|
from cyvest import Cyvest
|
|
37
|
+
from cyvest.model_schema import InvestigationSchema, StatisticsSchema
|
|
27
38
|
|
|
28
39
|
|
|
29
40
|
class SharedInvestigationContext:
|
|
@@ -156,15 +167,15 @@ class SharedInvestigationContext:
|
|
|
156
167
|
# Refresh registries from canonical, post-merge investigation state
|
|
157
168
|
self._observable_registry = {}
|
|
158
169
|
for obs in self._main_investigation.get_all_observables().values():
|
|
159
|
-
copy =
|
|
170
|
+
copy = obs.model_copy(deep=True)
|
|
160
171
|
copy._from_shared_context = True
|
|
161
172
|
self._observable_registry[obs.key] = copy
|
|
162
173
|
|
|
163
174
|
self._check_registry = {
|
|
164
|
-
check.key:
|
|
175
|
+
check.key: check.model_copy(deep=True) for check in self._main_investigation.get_all_checks().values()
|
|
165
176
|
}
|
|
166
177
|
self._enrichment_registry = {
|
|
167
|
-
enrichment.key:
|
|
178
|
+
enrichment.key: enrichment.model_copy(deep=True)
|
|
168
179
|
for enrichment in self._main_investigation.get_all_enrichments().values()
|
|
169
180
|
}
|
|
170
181
|
|
|
@@ -252,7 +263,7 @@ class SharedInvestigationContext:
|
|
|
252
263
|
with self._lock:
|
|
253
264
|
obs = self._observable_registry.get(key)
|
|
254
265
|
if obs:
|
|
255
|
-
copy =
|
|
266
|
+
copy = obs.model_copy(deep=True)
|
|
256
267
|
# Mark this as a copy from shared context to prevent misuse in relationships
|
|
257
268
|
copy._from_shared_context = True
|
|
258
269
|
return copy
|
|
@@ -310,7 +321,7 @@ class SharedInvestigationContext:
|
|
|
310
321
|
with self._lock:
|
|
311
322
|
check = self._check_registry.get(key)
|
|
312
323
|
if check:
|
|
313
|
-
return
|
|
324
|
+
return check.model_copy(deep=True)
|
|
314
325
|
return None
|
|
315
326
|
|
|
316
327
|
@overload
|
|
@@ -376,7 +387,7 @@ class SharedInvestigationContext:
|
|
|
376
387
|
with self._lock:
|
|
377
388
|
enrichment = self._enrichment_registry.get(key)
|
|
378
389
|
if enrichment:
|
|
379
|
-
return
|
|
390
|
+
return enrichment.model_copy(deep=True)
|
|
380
391
|
return None
|
|
381
392
|
|
|
382
393
|
def get_global_score(self) -> Decimal:
|
|
@@ -461,7 +472,7 @@ class SharedInvestigationContext:
|
|
|
461
472
|
matches = []
|
|
462
473
|
for obs in self._observable_registry.values():
|
|
463
474
|
if obs.obs_type == obs_type:
|
|
464
|
-
matches.append(
|
|
475
|
+
matches.append(obs.model_copy(deep=True))
|
|
465
476
|
return matches
|
|
466
477
|
|
|
467
478
|
def find_observables_by_value(self, value: str) -> list[Observable]:
|
|
@@ -478,7 +489,7 @@ class SharedInvestigationContext:
|
|
|
478
489
|
matches = []
|
|
479
490
|
for obs in self._observable_registry.values():
|
|
480
491
|
if obs.value == value:
|
|
481
|
-
matches.append(
|
|
492
|
+
matches.append(obs.model_copy(deep=True))
|
|
482
493
|
return matches
|
|
483
494
|
|
|
484
495
|
@overload
|
|
@@ -645,20 +656,19 @@ class SharedInvestigationContext:
|
|
|
645
656
|
save_investigation_markdown(temp_cy, filepath, include_containers, include_enrichments, include_observables)
|
|
646
657
|
return str(Path(filepath).resolve())
|
|
647
658
|
|
|
648
|
-
def io_to_dict(self) ->
|
|
659
|
+
def io_to_dict(self) -> InvestigationSchema:
|
|
649
660
|
"""
|
|
650
|
-
Serialize the shared investigation to
|
|
661
|
+
Serialize the shared investigation to an InvestigationSchema.
|
|
651
662
|
|
|
652
663
|
Thread-safe: Uses lock to ensure consistent read of investigation state.
|
|
653
664
|
|
|
654
665
|
Returns:
|
|
655
|
-
|
|
666
|
+
InvestigationSchema instance (use .model_dump() for dict)
|
|
656
667
|
|
|
657
668
|
Example:
|
|
658
669
|
>>> shared = SharedInvestigationContext(main_inv)
|
|
659
|
-
>>>
|
|
660
|
-
>>>
|
|
661
|
-
dict_keys(['score', 'level', 'whitelisted', 'observables', 'checks', ...])
|
|
670
|
+
>>> schema = shared.io_to_dict()
|
|
671
|
+
>>> dict_data = schema.model_dump(by_alias=True)
|
|
662
672
|
"""
|
|
663
673
|
from cyvest import Cyvest
|
|
664
674
|
from cyvest.io_serialization import serialize_investigation
|
|
@@ -704,35 +714,6 @@ class SharedInvestigationContext:
|
|
|
704
714
|
return str(Path(filepath).resolve())
|
|
705
715
|
|
|
706
716
|
|
|
707
|
-
@dataclass
|
|
708
|
-
class InvestigationWhitelist:
|
|
709
|
-
"""Represents a whitelist entry on an investigation."""
|
|
710
|
-
|
|
711
|
-
identifier: str
|
|
712
|
-
name: str
|
|
713
|
-
justification: str | None = None
|
|
714
|
-
|
|
715
|
-
def to_dict(self) -> dict[str, str | None]:
|
|
716
|
-
"""Serialize whitelist entry to a dictionary."""
|
|
717
|
-
return {
|
|
718
|
-
"identifier": self.identifier,
|
|
719
|
-
"name": self.name,
|
|
720
|
-
"justification": self.justification,
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
@classmethod
|
|
724
|
-
def from_dict(cls, data: dict[str, Any]) -> InvestigationWhitelist:
|
|
725
|
-
"""Construct a whitelist entry from a dictionary."""
|
|
726
|
-
justification = data.get("justification")
|
|
727
|
-
if justification is not None:
|
|
728
|
-
justification = str(justification)
|
|
729
|
-
return cls(
|
|
730
|
-
identifier=str(data.get("identifier", "")).strip(),
|
|
731
|
-
name=str(data.get("name", "")).strip(),
|
|
732
|
-
justification=justification,
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
|
|
736
717
|
class Investigation:
|
|
737
718
|
"""
|
|
738
719
|
Core investigation state and operations.
|
|
@@ -774,6 +755,8 @@ class Investigation:
|
|
|
774
755
|
root_type: Type of root observable ("file" or "artifact")
|
|
775
756
|
score_mode: Score calculation mode (MAX or SUM)
|
|
776
757
|
"""
|
|
758
|
+
self._started_at = datetime.now(timezone.utc)
|
|
759
|
+
|
|
777
760
|
# Object collections
|
|
778
761
|
self._observables: dict[str, Observable] = {}
|
|
779
762
|
self._checks: dict[str, Check] = {}
|
|
@@ -782,7 +765,7 @@ class Investigation:
|
|
|
782
765
|
self._containers: dict[str, Container] = {}
|
|
783
766
|
|
|
784
767
|
# Internal components
|
|
785
|
-
normalized_score_mode =
|
|
768
|
+
normalized_score_mode = ScoreMode.normalize(score_mode)
|
|
786
769
|
self._score_engine = ScoreEngine(score_mode=normalized_score_mode)
|
|
787
770
|
self._stats = InvestigationStats()
|
|
788
771
|
self._whitelists: dict[str, InvestigationWhitelist] = {}
|
|
@@ -796,7 +779,7 @@ class Investigation:
|
|
|
796
779
|
|
|
797
780
|
self._root_observable = Observable(
|
|
798
781
|
obs_type=obj_type,
|
|
799
|
-
value="
|
|
782
|
+
value="root",
|
|
800
783
|
internal=False,
|
|
801
784
|
whitelisted=False,
|
|
802
785
|
comment="Root observable for investigation",
|
|
@@ -843,7 +826,7 @@ class Investigation:
|
|
|
843
826
|
if existing.extra:
|
|
844
827
|
existing.extra.update(incoming.extra)
|
|
845
828
|
elif incoming.extra:
|
|
846
|
-
existing.extra = dict(
|
|
829
|
+
existing.extra = dict(incoming.extra)
|
|
847
830
|
|
|
848
831
|
# Concatenate comments
|
|
849
832
|
if incoming.comment:
|
|
@@ -983,11 +966,8 @@ class Investigation:
|
|
|
983
966
|
# Take the higher score
|
|
984
967
|
if incoming.score > existing.score:
|
|
985
968
|
existing.score = incoming.score
|
|
986
|
-
# Recalculate level
|
|
987
|
-
|
|
988
|
-
calculated_level = get_level_from_score(existing.score)
|
|
989
|
-
if calculated_level > existing.level:
|
|
990
|
-
existing.level = calculated_level
|
|
969
|
+
# Recalculate level from new score (SAFE remains sticky against downgrades)
|
|
970
|
+
existing.level = recalculate_level_for_score(existing.level, existing.score)
|
|
991
971
|
|
|
992
972
|
# Take the higher level
|
|
993
973
|
if incoming.level > existing.level:
|
|
@@ -1578,9 +1558,9 @@ class Investigation:
|
|
|
1578
1558
|
|
|
1579
1559
|
def get_whitelists(self) -> list[InvestigationWhitelist]:
|
|
1580
1560
|
"""Return a copy of all whitelist entries."""
|
|
1581
|
-
return
|
|
1561
|
+
return [w.model_copy(deep=True) for w in self._whitelists.values()]
|
|
1582
1562
|
|
|
1583
|
-
def get_statistics(self) ->
|
|
1563
|
+
def get_statistics(self) -> StatisticsSchema:
|
|
1584
1564
|
"""Get comprehensive investigation statistics."""
|
|
1585
1565
|
return self._stats.get_summary()
|
|
1586
1566
|
|