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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cyvest
3
- Version: 2.0.0
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="input-data"`) acts as a special barrier to prevent cross-contamination:
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 > schema.json
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="input-data"`) acts as a special barrier to prevent cross-contamination:
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 > schema.json
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 = "2.0.0"
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 CheckScorePolicy, ObservableType, RelationshipDirection, RelationshipType
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__ = "2.0.0"
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.get('total_observables', 0)}")
170
- logger.info(f" Total Checks: {investigation_stats.get('total_checks', 0)}")
171
- logger.info(f" Total Threat Intel: {investigation_stats.get('total_threat_intel', 0)}")
172
- logger.info(f" Total Containers: {investigation_stats.get('total_containers', 0)}")
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, normalize_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, normalize_score_mode
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 = normalize_score_mode(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
- resolved_level = normalize_level(level) if level is not None else Level.INFO
218
-
219
- obs = Observable(
220
- obs_type=obs_type,
221
- value=value,
222
- internal=internal,
223
- whitelisted=whitelisted,
224
- comment=comment,
225
- extra=extra or {},
226
- score=Decimal(str(score)) if score is not None else Decimal("0"),
227
- level=resolved_level,
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
- resolved_level = normalize_level(level) if level is not None else Level.INFO
308
-
309
- ti = ThreatIntel(
310
- source=source,
311
- observable_key=observable_key,
312
- comment=comment,
313
- extra=extra or {},
314
- score=Decimal(str(score)),
315
- level=resolved_level,
316
- taxonomies=taxonomies or [],
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(normalize_level(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
- resolved_level = normalize_level(level) if level is not None else Level.NONE
376
- resolved_policy = CheckScorePolicy(score_policy) if score_policy is not None else CheckScorePolicy.AUTO
377
-
378
- check = Check(
379
- check_id=check_id,
380
- scope=scope,
381
- description=description,
382
- comment=comment,
383
- extra=extra or {},
384
- score=Decimal(str(score)) if score is not None else Decimal("0"),
385
- level=resolved_level,
386
- score_policy=resolved_policy,
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) -> dict[str, Any]:
538
+ def get_statistics(self) -> StatisticsSchema:
536
539
  """
537
540
  Get comprehensive investigation statistics.
538
541
 
539
542
  Returns:
540
- Statistics dictionary
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) -> dict[str, Any]:
632
+ def io_to_dict(self) -> InvestigationSchema:
630
633
  """
631
- Serialize the investigation to a dictionary.
634
+ Serialize the investigation to an InvestigationSchema.
632
635
 
633
636
  Returns:
634
- Dictionary representation suitable for JSON export
637
+ InvestigationSchema instance (use .model_dump() for dict)
635
638
 
636
639
  Examples:
637
640
  >>> cv = Cyvest()
638
- >>> data = cv.io_to_dict()
639
- >>> print(data.keys())
640
- dict_keys(['score', 'level', 'observables', 'checks', ...])
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, show_graph: bool = True, exclude_levels: Level | str | Iterable[Level | str] = Level.NONE
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=normalized_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 dataclasses import dataclass
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.levels import Level, get_level_from_score, normalize_level
21
- from cyvest.model import Check, CheckScorePolicy, Container, Enrichment, Observable, ObservableType, ThreatIntel
22
- from cyvest.score import ScoreEngine, ScoreMode, normalize_score_mode
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 = deepcopy(obs)
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: deepcopy(check) for check in self._main_investigation.get_all_checks().values()
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: deepcopy(enrichment)
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 = deepcopy(obs)
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 deepcopy(check)
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 deepcopy(enrichment)
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(deepcopy(obs))
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(deepcopy(obs))
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) -> dict[str, Any]:
659
+ def io_to_dict(self) -> InvestigationSchema:
649
660
  """
650
- Serialize the shared investigation to a dictionary.
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
- Dictionary representation suitable for JSON export
666
+ InvestigationSchema instance (use .model_dump() for dict)
656
667
 
657
668
  Example:
658
669
  >>> shared = SharedInvestigationContext(main_inv)
659
- >>> data = shared.io_to_dict()
660
- >>> print(data.keys())
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 = normalize_score_mode(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="input-data",
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().update(incoming.extra)
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
- if not existing._explicit_level:
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 deepcopy(list(self._whitelists.values()))
1561
+ return [w.model_copy(deep=True) for w in self._whitelists.values()]
1582
1562
 
1583
- def get_statistics(self) -> dict[str, Any]:
1563
+ def get_statistics(self) -> StatisticsSchema:
1584
1564
  """Get comprehensive investigation statistics."""
1585
1565
  return self._stats.get_summary()
1586
1566