cyvest 4.4.0__py3-none-any.whl → 5.1.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/__init__.py CHANGED
@@ -7,32 +7,51 @@ programmatically with automatic scoring, level calculation, and rich reporting c
7
7
 
8
8
  from logurich import logger
9
9
 
10
+ from cyvest.compare import (
11
+ DiffItem,
12
+ DiffStatus,
13
+ ExpectedResult,
14
+ ObservableDiff,
15
+ ThreatIntelDiff,
16
+ compare_investigations,
17
+ )
10
18
  from cyvest.cyvest import Cyvest
11
19
  from cyvest.levels import Level
12
- from cyvest.model import Check, Container, Enrichment, InvestigationWhitelist, Observable, Taxonomy, ThreatIntel
20
+ from cyvest.model import Check, Enrichment, InvestigationWhitelist, Observable, Tag, Taxonomy, ThreatIntel
13
21
  from cyvest.model_enums import ObservableType, RelationshipDirection, RelationshipType
14
- from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
22
+ from cyvest.proxies import CheckProxy, EnrichmentProxy, ObservableProxy, TagProxy, ThreatIntelProxy
15
23
 
16
- __version__ = "4.4.0"
24
+ __version__ = "5.1.0"
17
25
 
18
26
  logger.disable("cyvest")
19
27
 
20
28
  __all__ = [
29
+ # Core class
21
30
  "Cyvest",
31
+ # Enums
22
32
  "Level",
23
33
  "ObservableType",
24
34
  "RelationshipDirection",
25
35
  "RelationshipType",
36
+ # Proxies
26
37
  "CheckProxy",
27
38
  "ObservableProxy",
28
39
  "ThreatIntelProxy",
29
40
  "EnrichmentProxy",
30
- "ContainerProxy",
31
- "Container",
41
+ "TagProxy",
42
+ # Models
43
+ "Tag",
32
44
  "Enrichment",
33
45
  "InvestigationWhitelist",
34
46
  "Check",
35
47
  "Observable",
36
48
  "ThreatIntel",
37
49
  "Taxonomy",
50
+ # Comparison module
51
+ "compare_investigations",
52
+ "ExpectedResult",
53
+ "DiffItem",
54
+ "DiffStatus",
55
+ "ObservableDiff",
56
+ "ThreatIntelDiff",
38
57
  ]
cyvest/cli.py CHANGED
@@ -17,6 +17,8 @@ from logurich.opt_click import click_logger_params
17
17
  from rich.console import Console
18
18
 
19
19
  from cyvest import __version__
20
+ from cyvest.compare import ExpectedResult, compare_investigations
21
+ from cyvest.io_rich import display_diff
20
22
  from cyvest.io_schema import get_investigation_schema
21
23
  from cyvest.io_serialization import load_investigation_json
22
24
  from cyvest.io_visualization import VisualizationDependencyMissingError
@@ -172,7 +174,7 @@ def merge(inputs: tuple[Path, ...], output: Path, output_format: str, stats: boo
172
174
  logger.info(f" Total Observables: {investigation_stats.total_observables}")
173
175
  logger.info(f" Total Checks: {investigation_stats.total_checks}")
174
176
  logger.info(f" Total Threat Intel: {investigation_stats.total_threat_intel}")
175
- logger.info(f" Total Containers: {investigation_stats.total_containers}")
177
+ logger.info(f" Total Tags: {investigation_stats.total_tags}")
176
178
  logger.info(f" Global Score: {main_investigation.get_global_score():.2f}")
177
179
  logger.info(f" Global Level: {main_investigation.get_global_level()}\n")
178
180
 
@@ -356,6 +358,66 @@ def visualize(
356
358
  logger.info("[cyan]Opening visualization in browser...[/cyan]")
357
359
 
358
360
 
361
+ @cli.command()
362
+ @click.argument("actual", type=click.Path(exists=True, dir_okay=False, path_type=Path))
363
+ @click.argument("expected", type=click.Path(exists=True, dir_okay=False, path_type=Path))
364
+ @click.option(
365
+ "-r",
366
+ "--rules",
367
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
368
+ help="JSON file with ExpectedResult tolerance rules.",
369
+ )
370
+ @click.option(
371
+ "--title",
372
+ default="Investigation Diff",
373
+ show_default=True,
374
+ help="Title for the diff table.",
375
+ )
376
+ def diff(actual: Path, expected: Path, rules: Path | None, title: str) -> None:
377
+ """
378
+ Compare two investigation JSON files and display differences.
379
+
380
+ ACTUAL is the investigation to validate (actual results).
381
+ EXPECTED is the reference investigation (expected results).
382
+
383
+ Optionally provide a rules file with tolerance rules in JSON format:
384
+
385
+ \b
386
+ [
387
+ {"check_name": "domain-check", "score": ">= 1.0"},
388
+ {"key": "chk:ai-analysis", "level": "SUSPICIOUS", "score": "< 3.0"}
389
+ ]
390
+
391
+ Supported operators: >=, <=, >, <, ==, !=
392
+ """
393
+ logger.info("[cyan]Comparing investigations...[/cyan]")
394
+ logger.info(f" Actual: {actual}")
395
+ logger.info(f" Expected: {expected}")
396
+
397
+ actual_cv = load_investigation_json(actual)
398
+ expected_cv = load_investigation_json(expected)
399
+
400
+ # Load tolerance rules if provided
401
+ result_expected: list[ExpectedResult] | None = None
402
+ if rules:
403
+ logger.info(f" Rules: {rules}")
404
+ with rules.open("r", encoding="utf-8") as f:
405
+ rules_data = json.load(f)
406
+ result_expected = [ExpectedResult(**r) for r in rules_data]
407
+
408
+ logger.info("")
409
+
410
+ # Compare investigations
411
+ diffs = compare_investigations(actual_cv, expected_cv, result_expected=result_expected)
412
+
413
+ if not diffs:
414
+ logger.info("[green]✓ No differences found[/green]")
415
+ return
416
+
417
+ # Display diff table
418
+ display_diff(diffs, lambda r: logger.rich("INFO", r, width=150), title=title)
419
+
420
+
359
421
  def main() -> None:
360
422
  """Entry point used by the console script."""
361
423
  cli()
cyvest/compare.py ADDED
@@ -0,0 +1,310 @@
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 ExpectedResult(BaseModel):
26
+ """Tolerance rule for a specific check."""
27
+
28
+ check_name: str | None = None
29
+ key: str | None = None
30
+ level: Level | None = None
31
+ score: str | None = None # Tolerance rule: ">= 0.01", "< 3", "== 1.0"
32
+
33
+ @model_validator(mode="after")
34
+ def validate_key_or_name(self) -> ExpectedResult:
35
+ if not self.check_name and not self.key:
36
+ raise ValueError("Either check_name or key must be provided")
37
+ # Derive key from check_name if not provided
38
+ if self.check_name and not self.key:
39
+ self.key = generate_check_key(self.check_name)
40
+ return self
41
+
42
+ model_config = {"extra": "forbid"}
43
+
44
+
45
+ class DiffStatus(str, Enum):
46
+ """Status indicating the type of difference found."""
47
+
48
+ ADDED = "+"
49
+ REMOVED = "-"
50
+ MISMATCH = "\u2717" # ✗
51
+
52
+
53
+ class ThreatIntelDiff(BaseModel):
54
+ """Diff info for threat intel attached to an observable."""
55
+
56
+ source: str
57
+ expected_score: Decimal | None = None
58
+ expected_level: Level | None = None
59
+ actual_score: Decimal | None = None
60
+ actual_level: Level | None = None
61
+
62
+
63
+ class ObservableDiff(BaseModel):
64
+ """Diff info for an observable linked to a check."""
65
+
66
+ observable_key: str
67
+ obs_type: str
68
+ value: str
69
+ expected_score: Decimal | None = None
70
+ expected_level: Level | None = None
71
+ actual_score: Decimal | None = None
72
+ actual_level: Level | None = None
73
+ threat_intel_diffs: list[ThreatIntelDiff] = Field(default_factory=list)
74
+
75
+
76
+ class DiffItem(BaseModel):
77
+ """A single difference found between investigations."""
78
+
79
+ status: DiffStatus
80
+ key: str
81
+ check_name: str
82
+ # Expected values (from expected investigation or rule)
83
+ expected_level: Level | None = None
84
+ expected_score: Decimal | None = None
85
+ expected_score_rule: str | None = None # e.g., ">= 1.0"
86
+ # Actual values
87
+ actual_level: Level | None = None
88
+ actual_score: Decimal | None = None
89
+ # Linked observables with their diffs
90
+ observable_diffs: list[ObservableDiff] = Field(default_factory=list)
91
+
92
+
93
+ def parse_score_rule(rule: str) -> tuple[str, Decimal]:
94
+ """
95
+ Parse score rule like '>= 0.01' into (operator, value).
96
+
97
+ Args:
98
+ rule: A score rule string (e.g., ">= 0.01", "< 3", "== 1.0")
99
+
100
+ Returns:
101
+ Tuple of (operator, threshold)
102
+
103
+ Raises:
104
+ ValueError: If the rule format is invalid
105
+ """
106
+ match = re.match(r"(>=|<=|>|<|==|!=)\s*(-?\d+\.?\d*)", rule.strip())
107
+ if not match:
108
+ raise ValueError(f"Invalid score rule: {rule}")
109
+ return match.group(1), Decimal(match.group(2))
110
+
111
+
112
+ def evaluate_score_rule(actual_score: Decimal, rule: str) -> bool:
113
+ """
114
+ Check if actual_score satisfies the rule.
115
+
116
+ Args:
117
+ actual_score: The score to evaluate
118
+ rule: A score rule string (e.g., ">= 0.01")
119
+
120
+ Returns:
121
+ True if the score satisfies the rule, False otherwise
122
+ """
123
+ operator, threshold = parse_score_rule(rule)
124
+ ops = {
125
+ ">=": lambda a, b: a >= b,
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
+ }
132
+ return ops[operator](actual_score, threshold)
133
+
134
+
135
+ def compare_investigations(
136
+ actual: Cyvest,
137
+ expected: Cyvest | None = None,
138
+ result_expected: list[ExpectedResult] | None = None,
139
+ ) -> list[DiffItem]:
140
+ """
141
+ Compare two investigations with optional tolerance rules.
142
+
143
+ Args:
144
+ actual: The investigation to validate (actual results)
145
+ expected: The reference investigation (expected results), optional
146
+ result_expected: Tolerance rules for specific checks
147
+
148
+ Returns:
149
+ List of DiffItem for all differences found
150
+ """
151
+ diffs: list[DiffItem] = []
152
+ rules = {r.key: r for r in (result_expected or [])}
153
+
154
+ actual_checks = actual.check_get_all()
155
+ expected_checks = expected.check_get_all() if expected else {}
156
+
157
+ all_keys = set(actual_checks.keys()) | set(expected_checks.keys())
158
+
159
+ for key in sorted(all_keys):
160
+ actual_check = actual_checks.get(key)
161
+ expected_check = expected_checks.get(key)
162
+ rule = rules.get(key)
163
+
164
+ if actual_check and not expected_check:
165
+ # Check added in actual
166
+ diffs.append(
167
+ _create_diff_item(
168
+ status=DiffStatus.ADDED,
169
+ actual_check=actual_check,
170
+ actual_cv=actual,
171
+ expected_cv=expected,
172
+ )
173
+ )
174
+ elif expected_check and not actual_check:
175
+ # Check removed from actual
176
+ diffs.append(
177
+ _create_diff_item(
178
+ status=DiffStatus.REMOVED,
179
+ expected_check=expected_check,
180
+ rule=rule,
181
+ expected_cv=expected,
182
+ )
183
+ )
184
+ else:
185
+ # Check exists in both - compare values
186
+ if _is_mismatch(expected_check, actual_check, rule):
187
+ diffs.append(
188
+ _create_diff_item(
189
+ status=DiffStatus.MISMATCH,
190
+ expected_check=expected_check,
191
+ actual_check=actual_check,
192
+ rule=rule,
193
+ actual_cv=actual,
194
+ expected_cv=expected,
195
+ )
196
+ )
197
+
198
+ return diffs
199
+
200
+
201
+ def _is_mismatch(
202
+ expected: CheckProxy,
203
+ actual: CheckProxy,
204
+ rule: ExpectedResult | None,
205
+ ) -> bool:
206
+ """Check if there's a mismatch, considering tolerance rules."""
207
+ # If scores and levels are equal, no mismatch
208
+ if expected.score == actual.score and expected.level == actual.level:
209
+ return False
210
+
211
+ # If there's a tolerance rule with score condition
212
+ if rule and rule.score:
213
+ # If actual satisfies the rule, it's OK (not a mismatch)
214
+ if evaluate_score_rule(actual.score, rule.score):
215
+ return False
216
+
217
+ # Scores/levels differ and no tolerance allows it
218
+ return True
219
+
220
+
221
+ def _create_diff_item(
222
+ status: DiffStatus,
223
+ actual_check: CheckProxy | None = None,
224
+ expected_check: CheckProxy | None = None,
225
+ rule: ExpectedResult | None = None,
226
+ actual_cv: Cyvest | None = None,
227
+ expected_cv: Cyvest | None = None,
228
+ ) -> DiffItem:
229
+ """Create a DiffItem with observable and threat intel context."""
230
+ check = actual_check or expected_check
231
+
232
+ # Build observable diffs from linked observables
233
+ observable_diffs: list[ObservableDiff] = []
234
+
235
+ # Collect observable keys from both checks
236
+ obs_keys: set[str] = set()
237
+ if actual_check:
238
+ obs_keys.update(link.observable_key for link in actual_check.observable_links)
239
+ if expected_check:
240
+ obs_keys.update(link.observable_key for link in expected_check.observable_links)
241
+
242
+ for obs_key in sorted(obs_keys):
243
+ actual_obs = actual_cv.observable_get(obs_key) if actual_cv else None
244
+ expected_obs = expected_cv.observable_get(obs_key) if expected_cv else None
245
+ obs = actual_obs or expected_obs
246
+
247
+ if not obs:
248
+ continue
249
+
250
+ # Build threat intel diffs for this observable
251
+ ti_diffs: list[ThreatIntelDiff] = []
252
+ ti_sources: set[str] = set()
253
+
254
+ if actual_obs:
255
+ ti_sources.update(ti.source for ti in actual_obs.threat_intels)
256
+ if expected_obs:
257
+ ti_sources.update(ti.source for ti in expected_obs.threat_intels)
258
+
259
+ for source in sorted(ti_sources):
260
+ actual_ti = (
261
+ next(
262
+ (ti for ti in actual_obs.threat_intels if ti.source == source),
263
+ None,
264
+ )
265
+ if actual_obs
266
+ else None
267
+ )
268
+ expected_ti = (
269
+ next(
270
+ (ti for ti in expected_obs.threat_intels if ti.source == source),
271
+ None,
272
+ )
273
+ if expected_obs
274
+ else None
275
+ )
276
+
277
+ ti_diffs.append(
278
+ ThreatIntelDiff(
279
+ source=source,
280
+ expected_score=expected_ti.score if expected_ti else None,
281
+ expected_level=expected_ti.level if expected_ti else None,
282
+ actual_score=actual_ti.score if actual_ti else None,
283
+ actual_level=actual_ti.level if actual_ti else None,
284
+ )
285
+ )
286
+
287
+ observable_diffs.append(
288
+ ObservableDiff(
289
+ observable_key=obs_key,
290
+ obs_type=str(obs.obs_type.value if hasattr(obs.obs_type, "value") else obs.obs_type),
291
+ value=obs.value,
292
+ expected_score=expected_obs.score if expected_obs else None,
293
+ expected_level=expected_obs.level if expected_obs else None,
294
+ actual_score=actual_obs.score if actual_obs else None,
295
+ actual_level=actual_obs.level if actual_obs else None,
296
+ threat_intel_diffs=ti_diffs,
297
+ )
298
+ )
299
+
300
+ return DiffItem(
301
+ status=status,
302
+ key=check.key,
303
+ check_name=check.check_name,
304
+ expected_level=expected_check.level if expected_check else (rule.level if rule else None),
305
+ expected_score=expected_check.score if expected_check else None,
306
+ expected_score_rule=rule.score if rule else None,
307
+ actual_level=actual_check.level if actual_check else None,
308
+ actual_score=actual_check.score if actual_check else None,
309
+ observable_diffs=observable_diffs,
310
+ )