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/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
+ )