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 +24 -5
- cyvest/cli.py +63 -1
- cyvest/compare.py +310 -0
- cyvest/cyvest.py +253 -181
- cyvest/investigation.py +276 -243
- cyvest/io_rich.py +141 -54
- cyvest/io_schema.py +1 -1
- cyvest/io_serialization.py +90 -91
- cyvest/keys.py +61 -18
- cyvest/model.py +55 -43
- cyvest/model_schema.py +9 -9
- cyvest/proxies.py +48 -50
- cyvest/shared.py +19 -19
- cyvest/stats.py +11 -36
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/METADATA +105 -12
- cyvest-5.1.0.dist-info/RECORD +24 -0
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/WHEEL +1 -1
- cyvest-4.4.0.dist-info/RECORD +0 -23
- {cyvest-4.4.0.dist-info → cyvest-5.1.0.dist-info}/entry_points.txt +0 -0
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,
|
|
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,
|
|
22
|
+
from cyvest.proxies import CheckProxy, EnrichmentProxy, ObservableProxy, TagProxy, ThreatIntelProxy
|
|
15
23
|
|
|
16
|
-
__version__ = "
|
|
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
|
-
"
|
|
31
|
-
|
|
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
|
|
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
|
+
)
|