cyvest 0.1.0__py3-none-any.whl → 5.1.3__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.
- cyvest/__init__.py +48 -38
- cyvest/cli.py +487 -0
- cyvest/compare.py +318 -0
- cyvest/cyvest.py +1431 -0
- cyvest/investigation.py +1682 -0
- cyvest/io_rich.py +1153 -0
- cyvest/io_schema.py +35 -0
- cyvest/io_serialization.py +465 -0
- cyvest/io_visualization.py +358 -0
- cyvest/keys.py +237 -0
- cyvest/level_score_rules.py +78 -0
- cyvest/levels.py +175 -0
- cyvest/model.py +595 -0
- cyvest/model_enums.py +69 -0
- cyvest/model_schema.py +164 -0
- cyvest/proxies.py +595 -0
- cyvest/score.py +473 -0
- cyvest/shared.py +508 -0
- cyvest/stats.py +291 -0
- cyvest/ulid.py +36 -0
- cyvest-5.1.3.dist-info/METADATA +632 -0
- cyvest-5.1.3.dist-info/RECORD +24 -0
- {cyvest-0.1.0.dist-info → cyvest-5.1.3.dist-info}/WHEEL +1 -2
- cyvest-5.1.3.dist-info/entry_points.txt +3 -0
- cyvest/builder.py +0 -182
- cyvest/check_tree.py +0 -117
- cyvest/models.py +0 -785
- cyvest/observable_registry.py +0 -69
- cyvest/report_render.py +0 -306
- cyvest/report_serialization.py +0 -237
- cyvest/visitors.py +0 -332
- cyvest-0.1.0.dist-info/METADATA +0 -110
- cyvest-0.1.0.dist-info/RECORD +0 -13
- cyvest-0.1.0.dist-info/licenses/LICENSE +0 -21
- cyvest-0.1.0.dist-info/top_level.txt +0 -1
cyvest/compare.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comparison module for Cyvest investigations.
|
|
3
|
+
|
|
4
|
+
Provides functionality to compare two investigations with optional tolerance rules,
|
|
5
|
+
identifying differences in checks, observables, and threat intelligence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, model_validator
|
|
16
|
+
|
|
17
|
+
from cyvest.keys import generate_check_key
|
|
18
|
+
from cyvest.levels import Level
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from cyvest.cyvest import Cyvest
|
|
22
|
+
from cyvest.proxies import CheckProxy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DiffStatus(str, Enum):
|
|
26
|
+
"""Status indicating the type of difference found."""
|
|
27
|
+
|
|
28
|
+
ADDED = "+"
|
|
29
|
+
REMOVED = "-"
|
|
30
|
+
MISMATCH = "\u2717" # ✗
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExpectedResult(BaseModel):
|
|
34
|
+
"""Tolerance rule for a specific check."""
|
|
35
|
+
|
|
36
|
+
check_name: str | None = None
|
|
37
|
+
key: str | None = None
|
|
38
|
+
level: Level | None = None
|
|
39
|
+
score: str | None = None # Tolerance rule: ">= 0.01", "< 3", "== 1.0"
|
|
40
|
+
ignore: set[DiffStatus] | None = None # Statuses to ignore: ADDED, REMOVED, MISMATCH
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def validate_key_or_name(self) -> ExpectedResult:
|
|
44
|
+
if not self.check_name and not self.key:
|
|
45
|
+
raise ValueError("Either check_name or key must be provided")
|
|
46
|
+
# Derive key from check_name if not provided
|
|
47
|
+
if self.check_name and not self.key:
|
|
48
|
+
self.key = generate_check_key(self.check_name)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
model_config = {"extra": "forbid"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ThreatIntelDiff(BaseModel):
|
|
55
|
+
"""Diff info for threat intel attached to an observable."""
|
|
56
|
+
|
|
57
|
+
source: str
|
|
58
|
+
expected_score: Decimal | None = None
|
|
59
|
+
expected_level: Level | None = None
|
|
60
|
+
actual_score: Decimal | None = None
|
|
61
|
+
actual_level: Level | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ObservableDiff(BaseModel):
|
|
65
|
+
"""Diff info for an observable linked to a check."""
|
|
66
|
+
|
|
67
|
+
observable_key: str
|
|
68
|
+
obs_type: str
|
|
69
|
+
value: str
|
|
70
|
+
expected_score: Decimal | None = None
|
|
71
|
+
expected_level: Level | None = None
|
|
72
|
+
actual_score: Decimal | None = None
|
|
73
|
+
actual_level: Level | None = None
|
|
74
|
+
threat_intel_diffs: list[ThreatIntelDiff] = Field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DiffItem(BaseModel):
|
|
78
|
+
"""A single difference found between investigations."""
|
|
79
|
+
|
|
80
|
+
status: DiffStatus
|
|
81
|
+
key: str
|
|
82
|
+
check_name: str
|
|
83
|
+
# Expected values (from expected investigation or rule)
|
|
84
|
+
expected_level: Level | None = None
|
|
85
|
+
expected_score: Decimal | None = None
|
|
86
|
+
expected_score_rule: str | None = None # e.g., ">= 1.0"
|
|
87
|
+
# Actual values
|
|
88
|
+
actual_level: Level | None = None
|
|
89
|
+
actual_score: Decimal | None = None
|
|
90
|
+
# Linked observables with their diffs
|
|
91
|
+
observable_diffs: list[ObservableDiff] = Field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_score_rule(rule: str) -> tuple[str, Decimal]:
|
|
95
|
+
"""
|
|
96
|
+
Parse score rule like '>= 0.01' into (operator, value).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
rule: A score rule string (e.g., ">= 0.01", "< 3", "== 1.0")
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tuple of (operator, threshold)
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If the rule format is invalid
|
|
106
|
+
"""
|
|
107
|
+
match = re.match(r"(>=|<=|>|<|==|!=)\s*(-?\d+\.?\d*)", rule.strip())
|
|
108
|
+
if not match:
|
|
109
|
+
raise ValueError(f"Invalid score rule: {rule}")
|
|
110
|
+
return match.group(1), Decimal(match.group(2))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def evaluate_score_rule(actual_score: Decimal, rule: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Check if actual_score satisfies the rule.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
actual_score: The score to evaluate
|
|
119
|
+
rule: A score rule string (e.g., ">= 0.01")
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if the score satisfies the rule, False otherwise
|
|
123
|
+
"""
|
|
124
|
+
operator, threshold = parse_score_rule(rule)
|
|
125
|
+
ops = {
|
|
126
|
+
">=": lambda a, b: a >= b,
|
|
127
|
+
"<=": lambda a, b: a <= b,
|
|
128
|
+
">": lambda a, b: a > b,
|
|
129
|
+
"<": lambda a, b: a < b,
|
|
130
|
+
"==": lambda a, b: a == b,
|
|
131
|
+
"!=": lambda a, b: a != b,
|
|
132
|
+
}
|
|
133
|
+
return ops[operator](actual_score, threshold)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compare_investigations(
|
|
137
|
+
actual: Cyvest,
|
|
138
|
+
expected: Cyvest | None = None,
|
|
139
|
+
result_expected: list[ExpectedResult] | None = None,
|
|
140
|
+
) -> list[DiffItem]:
|
|
141
|
+
"""
|
|
142
|
+
Compare two investigations with optional tolerance rules.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
actual: The investigation to validate (actual results)
|
|
146
|
+
expected: The reference investigation (expected results), optional
|
|
147
|
+
result_expected: Tolerance rules for specific checks
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
List of DiffItem for all differences found
|
|
151
|
+
"""
|
|
152
|
+
diffs: list[DiffItem] = []
|
|
153
|
+
rules = {r.key: r for r in (result_expected or [])}
|
|
154
|
+
|
|
155
|
+
actual_checks = actual.check_get_all()
|
|
156
|
+
expected_checks = expected.check_get_all() if expected else {}
|
|
157
|
+
|
|
158
|
+
all_keys = set(actual_checks.keys()) | set(expected_checks.keys())
|
|
159
|
+
|
|
160
|
+
for key in sorted(all_keys):
|
|
161
|
+
actual_check = actual_checks.get(key)
|
|
162
|
+
expected_check = expected_checks.get(key)
|
|
163
|
+
rule = rules.get(key)
|
|
164
|
+
|
|
165
|
+
if actual_check and not expected_check:
|
|
166
|
+
# Check added in actual - skip if rule ignores ADDED
|
|
167
|
+
if rule and rule.ignore and DiffStatus.ADDED in rule.ignore:
|
|
168
|
+
continue
|
|
169
|
+
diffs.append(
|
|
170
|
+
_create_diff_item(
|
|
171
|
+
status=DiffStatus.ADDED,
|
|
172
|
+
actual_check=actual_check,
|
|
173
|
+
actual_cv=actual,
|
|
174
|
+
expected_cv=expected,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
elif expected_check and not actual_check:
|
|
178
|
+
# Check removed from actual - skip if rule ignores REMOVED
|
|
179
|
+
if rule and rule.ignore and DiffStatus.REMOVED in rule.ignore:
|
|
180
|
+
continue
|
|
181
|
+
diffs.append(
|
|
182
|
+
_create_diff_item(
|
|
183
|
+
status=DiffStatus.REMOVED,
|
|
184
|
+
expected_check=expected_check,
|
|
185
|
+
rule=rule,
|
|
186
|
+
expected_cv=expected,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
# Check exists in both - compare values
|
|
191
|
+
if _is_mismatch(expected_check, actual_check, rule):
|
|
192
|
+
# Skip if rule ignores MISMATCH
|
|
193
|
+
if rule and rule.ignore and DiffStatus.MISMATCH in rule.ignore:
|
|
194
|
+
continue
|
|
195
|
+
diffs.append(
|
|
196
|
+
_create_diff_item(
|
|
197
|
+
status=DiffStatus.MISMATCH,
|
|
198
|
+
expected_check=expected_check,
|
|
199
|
+
actual_check=actual_check,
|
|
200
|
+
rule=rule,
|
|
201
|
+
actual_cv=actual,
|
|
202
|
+
expected_cv=expected,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return diffs
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_mismatch(
|
|
210
|
+
expected: CheckProxy,
|
|
211
|
+
actual: CheckProxy,
|
|
212
|
+
rule: ExpectedResult | None,
|
|
213
|
+
) -> bool:
|
|
214
|
+
"""Check if there's a mismatch, considering tolerance rules."""
|
|
215
|
+
# If scores and levels are equal, no mismatch
|
|
216
|
+
if expected.score == actual.score and expected.level == actual.level:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
# If there's a tolerance rule with score condition
|
|
220
|
+
if rule and rule.score:
|
|
221
|
+
# If actual satisfies the rule, it's OK (not a mismatch)
|
|
222
|
+
if evaluate_score_rule(actual.score, rule.score):
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
# Scores/levels differ and no tolerance allows it
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _create_diff_item(
|
|
230
|
+
status: DiffStatus,
|
|
231
|
+
actual_check: CheckProxy | None = None,
|
|
232
|
+
expected_check: CheckProxy | None = None,
|
|
233
|
+
rule: ExpectedResult | None = None,
|
|
234
|
+
actual_cv: Cyvest | None = None,
|
|
235
|
+
expected_cv: Cyvest | None = None,
|
|
236
|
+
) -> DiffItem:
|
|
237
|
+
"""Create a DiffItem with observable and threat intel context."""
|
|
238
|
+
check = actual_check or expected_check
|
|
239
|
+
|
|
240
|
+
# Build observable diffs from linked observables
|
|
241
|
+
observable_diffs: list[ObservableDiff] = []
|
|
242
|
+
|
|
243
|
+
# Collect observable keys from both checks
|
|
244
|
+
obs_keys: set[str] = set()
|
|
245
|
+
if actual_check:
|
|
246
|
+
obs_keys.update(link.observable_key for link in actual_check.observable_links)
|
|
247
|
+
if expected_check:
|
|
248
|
+
obs_keys.update(link.observable_key for link in expected_check.observable_links)
|
|
249
|
+
|
|
250
|
+
for obs_key in sorted(obs_keys):
|
|
251
|
+
actual_obs = actual_cv.observable_get(obs_key) if actual_cv else None
|
|
252
|
+
expected_obs = expected_cv.observable_get(obs_key) if expected_cv else None
|
|
253
|
+
obs = actual_obs or expected_obs
|
|
254
|
+
|
|
255
|
+
if not obs:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Build threat intel diffs for this observable
|
|
259
|
+
ti_diffs: list[ThreatIntelDiff] = []
|
|
260
|
+
ti_sources: set[str] = set()
|
|
261
|
+
|
|
262
|
+
if actual_obs:
|
|
263
|
+
ti_sources.update(ti.source for ti in actual_obs.threat_intels)
|
|
264
|
+
if expected_obs:
|
|
265
|
+
ti_sources.update(ti.source for ti in expected_obs.threat_intels)
|
|
266
|
+
|
|
267
|
+
for source in sorted(ti_sources):
|
|
268
|
+
actual_ti = (
|
|
269
|
+
next(
|
|
270
|
+
(ti for ti in actual_obs.threat_intels if ti.source == source),
|
|
271
|
+
None,
|
|
272
|
+
)
|
|
273
|
+
if actual_obs
|
|
274
|
+
else None
|
|
275
|
+
)
|
|
276
|
+
expected_ti = (
|
|
277
|
+
next(
|
|
278
|
+
(ti for ti in expected_obs.threat_intels if ti.source == source),
|
|
279
|
+
None,
|
|
280
|
+
)
|
|
281
|
+
if expected_obs
|
|
282
|
+
else None
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
ti_diffs.append(
|
|
286
|
+
ThreatIntelDiff(
|
|
287
|
+
source=source,
|
|
288
|
+
expected_score=expected_ti.score if expected_ti else None,
|
|
289
|
+
expected_level=expected_ti.level if expected_ti else None,
|
|
290
|
+
actual_score=actual_ti.score if actual_ti else None,
|
|
291
|
+
actual_level=actual_ti.level if actual_ti else None,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
observable_diffs.append(
|
|
296
|
+
ObservableDiff(
|
|
297
|
+
observable_key=obs_key,
|
|
298
|
+
obs_type=str(obs.obs_type.value if hasattr(obs.obs_type, "value") else obs.obs_type),
|
|
299
|
+
value=obs.value,
|
|
300
|
+
expected_score=expected_obs.score if expected_obs else None,
|
|
301
|
+
expected_level=expected_obs.level if expected_obs else None,
|
|
302
|
+
actual_score=actual_obs.score if actual_obs else None,
|
|
303
|
+
actual_level=actual_obs.level if actual_obs else None,
|
|
304
|
+
threat_intel_diffs=ti_diffs,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return DiffItem(
|
|
309
|
+
status=status,
|
|
310
|
+
key=check.key,
|
|
311
|
+
check_name=check.check_name,
|
|
312
|
+
expected_level=expected_check.level if expected_check else (rule.level if rule else None),
|
|
313
|
+
expected_score=expected_check.score if expected_check else None,
|
|
314
|
+
expected_score_rule=rule.score if rule else None,
|
|
315
|
+
actual_level=actual_check.level if actual_check else None,
|
|
316
|
+
actual_score=actual_check.score if actual_check else None,
|
|
317
|
+
observable_diffs=observable_diffs,
|
|
318
|
+
)
|