cyvest 4.4.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 ADDED
@@ -0,0 +1,583 @@
1
+ """
2
+ Core data models for Cyvest investigation framework.
3
+
4
+ Defines the base classes for Check, Observable, ThreatIntel, Enrichment, Container,
5
+ and InvestigationWhitelist using Pydantic BaseModel.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
12
+ from typing import Annotated, Any
13
+
14
+ from pydantic import (
15
+ BaseModel,
16
+ ConfigDict,
17
+ Field,
18
+ PrivateAttr,
19
+ StrictStr,
20
+ computed_field,
21
+ field_serializer,
22
+ field_validator,
23
+ model_validator,
24
+ )
25
+ from typing_extensions import Self
26
+
27
+ from cyvest import keys
28
+ from cyvest.level_score_rules import apply_creation_score_level_defaults
29
+ from cyvest.levels import Level, get_level_from_score, normalize_level
30
+ from cyvest.model_enums import (
31
+ ObservableType,
32
+ PropagationMode,
33
+ RelationshipDirection,
34
+ RelationshipType,
35
+ )
36
+
37
+ _DEFAULT_SCORE_PLACES = 2
38
+
39
+
40
+ def _format_score_decimal(value: Decimal | None, *, places: int = _DEFAULT_SCORE_PLACES) -> str:
41
+ if value is None:
42
+ return "-"
43
+ if places < 0:
44
+ raise ValueError("places must be >= 0")
45
+ quantizer = Decimal("1").scaleb(-places)
46
+ try:
47
+ quantized = value.quantize(quantizer, rounding=ROUND_HALF_UP)
48
+ if quantized == 0:
49
+ quantized = Decimal("0").quantize(quantizer)
50
+ return format(quantized, "f")
51
+ except InvalidOperation:
52
+ return str(value)
53
+
54
+
55
+ class AuditEvent(BaseModel):
56
+ """Centralized audit event for investigation-level changes."""
57
+
58
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
59
+
60
+ event_id: str
61
+ timestamp: datetime
62
+ event_type: str
63
+ actor: str | None = None
64
+ reason: str | None = None
65
+ tool: str | None = None
66
+ object_type: str | None = None
67
+ object_key: str | None = None
68
+ details: dict[str, Any] = Field(default_factory=dict)
69
+
70
+
71
+ class InvestigationWhitelist(BaseModel):
72
+ """Represents a whitelist entry on an investigation."""
73
+
74
+ model_config = ConfigDict(str_strip_whitespace=True, frozen=True)
75
+
76
+ identifier: Annotated[str, Field(min_length=1)]
77
+ name: Annotated[str, Field(min_length=1)]
78
+ justification: str | None = None
79
+
80
+
81
+ class Relationship(BaseModel):
82
+ """Represents a relationship between observables."""
83
+
84
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
85
+
86
+ target_key: str = Field(...)
87
+ relationship_type: RelationshipType | str = Field(...)
88
+ direction: RelationshipDirection = Field(...)
89
+
90
+ @model_validator(mode="before")
91
+ @classmethod
92
+ def ensure_defaults(cls, values: Any) -> Any:
93
+ if not isinstance(values, dict):
94
+ return values
95
+ if values.get("direction") is None:
96
+ rel_type = values.get("relationship_type")
97
+
98
+ # Use semantic default when relationship type is known, otherwise fall back to outbound.
99
+ default_direction = RelationshipDirection.OUTBOUND
100
+ if isinstance(rel_type, RelationshipType):
101
+ default_direction = rel_type.get_default_direction()
102
+ else:
103
+ try:
104
+ rel_enum = RelationshipType(rel_type)
105
+ default_direction = rel_enum.get_default_direction()
106
+ values["relationship_type"] = rel_enum
107
+ except Exception:
108
+ # Unknown type: keep fallback outbound
109
+ pass
110
+
111
+ values["direction"] = default_direction
112
+ return values
113
+
114
+ @field_validator("relationship_type", mode="before")
115
+ @classmethod
116
+ def coerce_relationship_type(cls, v: Any) -> RelationshipType | str:
117
+ """Normalize relationship type to enum if possible."""
118
+ if isinstance(v, RelationshipType):
119
+ return v
120
+ if isinstance(v, str):
121
+ try:
122
+ return RelationshipType(v)
123
+ except ValueError:
124
+ # Keep as string if not a recognized relationship type
125
+ return v
126
+ return v
127
+
128
+ @field_serializer("relationship_type")
129
+ def serialize_relationship_type(self, v: RelationshipType | str) -> str:
130
+ return v.value if isinstance(v, RelationshipType) else v
131
+
132
+ @field_validator("direction", mode="before")
133
+ @classmethod
134
+ def coerce_direction(cls, v: Any) -> RelationshipDirection:
135
+ if v is None:
136
+ return RelationshipDirection.OUTBOUND
137
+ if isinstance(v, RelationshipDirection):
138
+ return v
139
+ if isinstance(v, str):
140
+ return RelationshipDirection(v)
141
+ raise TypeError("Invalid direction type")
142
+
143
+ @property
144
+ def relationship_type_name(self) -> str:
145
+ return (
146
+ self.relationship_type.value
147
+ if isinstance(self.relationship_type, RelationshipType)
148
+ else self.relationship_type
149
+ )
150
+
151
+
152
+ class Taxonomy(BaseModel):
153
+ """Represents a structured taxonomy entry for threat intelligence."""
154
+
155
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
156
+
157
+ level: Level = Field(...)
158
+ name: StrictStr = Field(...)
159
+ value: StrictStr = Field(...)
160
+
161
+ @field_validator("level", mode="before")
162
+ @classmethod
163
+ def coerce_level(cls, v: Any) -> Level:
164
+ return normalize_level(v)
165
+
166
+
167
+ class ThreatIntel(BaseModel):
168
+ """
169
+ Represents threat intelligence from an external source.
170
+
171
+ Threat intelligence provides verdicts about observables from sources
172
+ like VirusTotal, URLScan.io, etc.
173
+ """
174
+
175
+ model_config = ConfigDict(arbitrary_types_allowed=True)
176
+
177
+ source: str = Field(...)
178
+ observable_key: str = Field(...)
179
+ comment: str = Field(...)
180
+ extra: dict[str, Any] = Field(...)
181
+ score: Decimal = Field(...)
182
+ level: Level = Field(...)
183
+ taxonomies: list[Taxonomy] = Field(...)
184
+ key: str = Field(...)
185
+
186
+ @field_validator("extra", mode="before")
187
+ @classmethod
188
+ def coerce_extra(cls, v: Any) -> dict[str, Any]:
189
+ if v is None:
190
+ return {}
191
+ return v
192
+
193
+ @field_validator("score", mode="before")
194
+ @classmethod
195
+ def coerce_score(cls, v: Any) -> Decimal:
196
+ if isinstance(v, Decimal):
197
+ return v
198
+ return Decimal(str(v))
199
+
200
+ @field_validator("level", mode="before")
201
+ @classmethod
202
+ def coerce_level(cls, v: Any) -> Level:
203
+ return normalize_level(v)
204
+
205
+ @field_validator("taxonomies")
206
+ @classmethod
207
+ def ensure_unique_taxonomy_names(cls, v: list[Taxonomy]) -> list[Taxonomy]:
208
+ seen: set[str] = set()
209
+ duplicates: set[str] = set()
210
+ for taxonomy in v:
211
+ if taxonomy.name in seen:
212
+ duplicates.add(taxonomy.name)
213
+ seen.add(taxonomy.name)
214
+ if duplicates:
215
+ dupes = ", ".join(sorted(duplicates))
216
+ raise ValueError(f"Duplicate taxonomy name(s): {dupes}")
217
+ return v
218
+
219
+ @model_validator(mode="before")
220
+ @classmethod
221
+ def ensure_defaults(cls, values: Any) -> Any:
222
+ values = apply_creation_score_level_defaults(
223
+ values,
224
+ default_level_no_score=Level.INFO,
225
+ require_score=True,
226
+ )
227
+ if not isinstance(values, dict):
228
+ return values
229
+
230
+ if values.get("observable_key") is None:
231
+ values["observable_key"] = ""
232
+ if "extra" not in values:
233
+ values["extra"] = {}
234
+ if "comment" not in values:
235
+ values["comment"] = ""
236
+ if values.get("taxonomies") is None:
237
+ values["taxonomies"] = []
238
+ if "key" not in values:
239
+ values["key"] = ""
240
+ return values
241
+
242
+ @model_validator(mode="after")
243
+ def generate_key(self) -> Self:
244
+ """Generate key."""
245
+ if not self.key and self.observable_key:
246
+ self.key = keys.generate_threat_intel_key(self.source, self.observable_key)
247
+
248
+ return self
249
+
250
+ @field_serializer("score")
251
+ def serialize_score(self, v: Decimal) -> float:
252
+ return float(v)
253
+
254
+ @computed_field(return_type=str)
255
+ @property
256
+ def score_display(self) -> str:
257
+ return _format_score_decimal(self.score)
258
+
259
+
260
+ class Observable(BaseModel):
261
+ """
262
+ Represents a cyber observable (IP, URL, domain, hash, etc.).
263
+
264
+ Observables can be linked to threat intelligence, checks, and other observables
265
+ through relationships.
266
+ """
267
+
268
+ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
269
+
270
+ obs_type: ObservableType | str = Field(..., alias="type")
271
+ value: str = Field(...)
272
+ internal: bool = Field(...)
273
+ whitelisted: bool = Field(...)
274
+ comment: str = Field(...)
275
+ extra: dict[str, Any] = Field(...)
276
+ score: Decimal = Field(...)
277
+ level: Level = Field(...)
278
+ threat_intels: list[ThreatIntel] = Field(...)
279
+ relationships: list[Relationship] = Field(...)
280
+ key: str = Field(...)
281
+ _check_links: list[str] = PrivateAttr(default_factory=list)
282
+ _from_shared_context: bool = PrivateAttr(default=False)
283
+
284
+ @field_validator("obs_type", mode="before")
285
+ @classmethod
286
+ def coerce_obs_type(cls, v: Any) -> ObservableType | str:
287
+ if isinstance(v, ObservableType):
288
+ return v
289
+ if isinstance(v, str):
290
+ try:
291
+ # Try case-insensitive match first
292
+ return ObservableType(v.lower())
293
+ except ValueError:
294
+ # Keep as string if not a recognized observable type
295
+ return v
296
+ return v
297
+
298
+ @field_validator("extra", mode="before")
299
+ @classmethod
300
+ def coerce_extra(cls, v: Any) -> dict[str, Any]:
301
+ if v is None:
302
+ return {}
303
+ return v
304
+
305
+ @field_validator("score", mode="before")
306
+ @classmethod
307
+ def coerce_score(cls, v: Any) -> Decimal:
308
+ if isinstance(v, Decimal):
309
+ return v
310
+ return Decimal(str(v))
311
+
312
+ @field_validator("level", mode="before")
313
+ @classmethod
314
+ def coerce_level(cls, v: Any) -> Level:
315
+ return normalize_level(v)
316
+
317
+ @model_validator(mode="before")
318
+ @classmethod
319
+ def ensure_defaults(cls, values: Any) -> Any:
320
+ values = apply_creation_score_level_defaults(values, default_level_no_score=Level.INFO)
321
+ if not isinstance(values, dict):
322
+ return values
323
+
324
+ if "extra" not in values:
325
+ values["extra"] = {}
326
+ if "comment" not in values:
327
+ values["comment"] = ""
328
+ if "internal" not in values:
329
+ values["internal"] = True
330
+ if "whitelisted" not in values:
331
+ values["whitelisted"] = False
332
+ if "threat_intels" not in values:
333
+ values["threat_intels"] = []
334
+ if "relationships" not in values:
335
+ values["relationships"] = []
336
+ if "key" not in values:
337
+ values["key"] = ""
338
+ return values
339
+
340
+ @model_validator(mode="after")
341
+ def generate_key(self) -> Self:
342
+ """Generate key."""
343
+ if not self.key:
344
+ # Use string value of obs_type for key generation
345
+ obs_type_str = self.obs_type.value if isinstance(self.obs_type, ObservableType) else self.obs_type
346
+ self.key = keys.generate_observable_key(obs_type_str, self.value)
347
+
348
+ return self
349
+
350
+ @field_serializer("obs_type")
351
+ def serialize_obs_type(self, v: ObservableType | str) -> str:
352
+ return v.value if isinstance(v, ObservableType) else v
353
+
354
+ @field_serializer("score")
355
+ def serialize_score(self, v: Decimal) -> float:
356
+ return float(v)
357
+
358
+ @field_serializer("threat_intels")
359
+ def serialize_threat_intels(self, value: list[ThreatIntel]) -> list[str]:
360
+ """Serialize threat intels as keys only."""
361
+ return [ti.key for ti in value]
362
+
363
+ @computed_field
364
+ @property
365
+ def check_links(self) -> list[str]:
366
+ """Checks that currently link to this observable (navigation-only)."""
367
+ return list(self._check_links)
368
+
369
+ @computed_field(return_type=str)
370
+ @property
371
+ def score_display(self) -> str:
372
+ return _format_score_decimal(self.score)
373
+
374
+
375
+ class ObservableLink(BaseModel):
376
+ """Edge metadata for a Check↔Observable association."""
377
+
378
+ model_config = ConfigDict(extra="forbid", frozen=True)
379
+
380
+ observable_key: str = Field(...)
381
+ propagation_mode: PropagationMode = PropagationMode.LOCAL_ONLY
382
+
383
+
384
+ class Check(BaseModel):
385
+ """
386
+ Represents a verification step in the investigation.
387
+
388
+ A check validates a specific aspect of the data under investigation
389
+ and contributes to the overall investigation score.
390
+ """
391
+
392
+ model_config = ConfigDict(arbitrary_types_allowed=True)
393
+
394
+ check_id: str = Field(...)
395
+ scope: str = Field(...)
396
+ description: str = Field(...)
397
+ comment: str = Field(...)
398
+ extra: dict[str, Any] = Field(...)
399
+ score: Decimal = Field(...)
400
+ level: Level = Field(...)
401
+ origin_investigation_id: str = Field(...)
402
+ observable_links: list[ObservableLink] = Field(...)
403
+ key: str = Field(...)
404
+
405
+ @field_validator("extra", mode="before")
406
+ @classmethod
407
+ def coerce_extra(cls, v: Any) -> dict[str, Any]:
408
+ if v is None:
409
+ return {}
410
+ return v
411
+
412
+ @field_validator("score", mode="before")
413
+ @classmethod
414
+ def coerce_score(cls, v: Any) -> Decimal:
415
+ if isinstance(v, Decimal):
416
+ return v
417
+ return Decimal(str(v))
418
+
419
+ @field_validator("level", mode="before")
420
+ @classmethod
421
+ def coerce_level(cls, v: Any) -> Level:
422
+ return normalize_level(v)
423
+
424
+ @model_validator(mode="before")
425
+ @classmethod
426
+ def ensure_defaults(cls, values: Any) -> Any:
427
+ values = apply_creation_score_level_defaults(values, default_level_no_score=Level.NONE)
428
+ if not isinstance(values, dict):
429
+ return values
430
+
431
+ if "extra" not in values:
432
+ values["extra"] = {}
433
+ if "comment" not in values:
434
+ values["comment"] = ""
435
+ if "observable_links" not in values:
436
+ values["observable_links"] = []
437
+ if "key" not in values:
438
+ values["key"] = ""
439
+ return values
440
+
441
+ @model_validator(mode="after")
442
+ def generate_key(self) -> Self:
443
+ """Generate key."""
444
+ if not self.key:
445
+ self.key = keys.generate_check_key(self.check_id, self.scope)
446
+ return self
447
+
448
+ @field_serializer("score")
449
+ def serialize_score(self, v: Decimal) -> float:
450
+ return float(v)
451
+
452
+ @computed_field(return_type=str)
453
+ @property
454
+ def score_display(self) -> str:
455
+ return _format_score_decimal(self.score)
456
+
457
+
458
+ class Enrichment(BaseModel):
459
+ """
460
+ Represents structured data enrichment for the investigation.
461
+
462
+ Enrichments store arbitrary structured data that provides additional
463
+ context but doesn't directly contribute to scoring.
464
+ """
465
+
466
+ model_config = ConfigDict()
467
+
468
+ name: str = Field(...)
469
+ data: Any = Field(...)
470
+ context: str = Field(...)
471
+ key: str = Field(...)
472
+
473
+ @model_validator(mode="after")
474
+ def generate_key(self) -> Self:
475
+ """Generate key."""
476
+ if not self.key:
477
+ self.key = keys.generate_enrichment_key(self.name, self.context)
478
+ return self
479
+
480
+ @model_validator(mode="before")
481
+ @classmethod
482
+ def ensure_defaults(cls, values: Any) -> Any:
483
+ if not isinstance(values, dict):
484
+ return values
485
+ if "data" not in values:
486
+ values["data"] = {}
487
+ if "context" not in values:
488
+ values["context"] = ""
489
+ if "key" not in values:
490
+ values["key"] = ""
491
+ return values
492
+
493
+
494
+ class Container(BaseModel):
495
+ """
496
+ Groups checks and sub-containers for hierarchical organization.
497
+
498
+ Containers allow structuring the investigation into logical sections
499
+ with aggregated scores and levels.
500
+ """
501
+
502
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
503
+
504
+ path: str
505
+ description: str = ""
506
+ checks: list[Check] = Field(...)
507
+ sub_containers: dict[str, Container] = Field(...)
508
+ key: str = Field(...)
509
+
510
+ @model_validator(mode="after")
511
+ def generate_key(self) -> Self:
512
+ """Generate key."""
513
+ if not self.key:
514
+ self.key = keys.generate_container_key(self.path)
515
+ return self
516
+
517
+ @model_validator(mode="before")
518
+ @classmethod
519
+ def ensure_defaults(cls, values: Any) -> Any:
520
+ if not isinstance(values, dict):
521
+ return values
522
+ if "checks" not in values:
523
+ values["checks"] = []
524
+ if "sub_containers" not in values:
525
+ values["sub_containers"] = {}
526
+ if "key" not in values:
527
+ values["key"] = ""
528
+ return values
529
+
530
+ @computed_field(return_type=Decimal)
531
+ @property
532
+ def aggregated_score(self) -> Decimal:
533
+ return self.get_aggregated_score()
534
+
535
+ @field_serializer("aggregated_score")
536
+ def serialize_aggregated_score(self, v: Decimal) -> float:
537
+ return float(v)
538
+
539
+ def get_aggregated_score(self) -> Decimal:
540
+ """
541
+ Calculate the aggregated score from all checks and sub-containers.
542
+
543
+ Returns:
544
+ Total aggregated score
545
+ """
546
+ total = Decimal("0")
547
+ # Sum scores from direct checks
548
+ for check in self.checks:
549
+ total += check.score
550
+ # Sum scores from sub-containers
551
+ for sub in self.sub_containers.values():
552
+ total += sub.get_aggregated_score()
553
+ return total
554
+
555
+ @computed_field(return_type=Level)
556
+ @property
557
+ def aggregated_level(self) -> Level:
558
+ """
559
+ Calculate the aggregated level from the aggregated score.
560
+
561
+ Returns:
562
+ Level based on aggregated score
563
+ """
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()}
575
+
576
+ def get_aggregated_level(self) -> Level:
577
+ """
578
+ Calculate the aggregated level from the aggregated score.
579
+
580
+ Returns:
581
+ Level based on aggregated score
582
+ """
583
+ return get_level_from_score(self.get_aggregated_score())
cyvest/model_enums.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Shared enum types for Cyvest models.
3
+
4
+ This module intentionally contains only enums (no Pydantic models) so it can be
5
+ imported by both ``cyvest.model`` and ``cyvest.score`` without creating circular
6
+ import dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+
14
+ class ObservableType(str, Enum):
15
+ """Cyber observable types."""
16
+
17
+ IPV4 = "ipv4"
18
+ IPV6 = "ipv6"
19
+ DOMAIN = "domain"
20
+ URL = "url"
21
+ HASH = "hash"
22
+ EMAIL = "email"
23
+ FILE = "file"
24
+ ARTIFACT = "artifact"
25
+
26
+ @classmethod
27
+ def normalize_root_type(cls, root_type: ObservableType | str | None) -> ObservableType:
28
+ if root_type is None:
29
+ return cls.FILE
30
+ if isinstance(root_type, cls):
31
+ normalized = root_type
32
+ elif isinstance(root_type, str):
33
+ try:
34
+ normalized = cls(root_type.lower())
35
+ except ValueError as exc:
36
+ raise ValueError("root_type must be ObservableType.FILE or ObservableType.ARTIFACT") from exc
37
+ else:
38
+ raise TypeError("root_type must be ObservableType.FILE or ObservableType.ARTIFACT")
39
+
40
+ if normalized not in (cls.FILE, cls.ARTIFACT):
41
+ raise ValueError("root_type must be ObservableType.FILE or ObservableType.ARTIFACT")
42
+ return normalized
43
+
44
+
45
+ class RelationshipDirection(str, Enum):
46
+ """Direction of a relationship between observables."""
47
+
48
+ OUTBOUND = "outbound" # Source → Target
49
+ INBOUND = "inbound" # Source ← Target
50
+ BIDIRECTIONAL = "bidirectional" # Source ↔ Target
51
+
52
+
53
+ class RelationshipType(str, Enum):
54
+ """Relationship types supported by Cyvest."""
55
+
56
+ RELATED_TO = "related-to"
57
+
58
+ def get_default_direction(self) -> RelationshipDirection:
59
+ """
60
+ Get the default direction for this relationship type.
61
+ """
62
+ return RelationshipDirection.BIDIRECTIONAL
63
+
64
+
65
+ class PropagationMode(str, Enum):
66
+ """Controls how a Check↔Observable link propagates across merged investigations."""
67
+
68
+ LOCAL_ONLY = "LOCAL_ONLY"
69
+ GLOBAL = "GLOBAL"