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/__init__.py +38 -0
- cyvest/cli.py +365 -0
- cyvest/cyvest.py +1261 -0
- cyvest/investigation.py +1644 -0
- cyvest/io_rich.py +579 -0
- cyvest/io_schema.py +35 -0
- cyvest/io_serialization.py +459 -0
- cyvest/io_visualization.py +358 -0
- cyvest/keys.py +194 -0
- cyvest/level_score_rules.py +78 -0
- cyvest/levels.py +175 -0
- cyvest/model.py +583 -0
- cyvest/model_enums.py +69 -0
- cyvest/model_schema.py +164 -0
- cyvest/proxies.py +582 -0
- cyvest/score.py +473 -0
- cyvest/shared.py +496 -0
- cyvest/stats.py +316 -0
- cyvest/ulid.py +36 -0
- cyvest-4.4.0.dist-info/METADATA +538 -0
- cyvest-4.4.0.dist-info/RECORD +23 -0
- cyvest-4.4.0.dist-info/WHEEL +4 -0
- cyvest-4.4.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Network graph visualization for Cyvest investigations using pyvis.
|
|
3
|
+
|
|
4
|
+
Provides interactive HTML network visualization of observables and their relationships.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tempfile
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from cyvest.levels import LEVEL_COLORS, Level
|
|
15
|
+
from cyvest.model import ObservableType, RelationshipDirection, RelationshipType
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pyvis.network import Network
|
|
19
|
+
|
|
20
|
+
from cyvest.cyvest import Cyvest
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from pyvis.network import Network # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
PYVIS_AVAILABLE = True
|
|
26
|
+
except ImportError: # pragma: no cover - optional dependency not installed
|
|
27
|
+
Network = None # type: ignore[assignment]
|
|
28
|
+
PYVIS_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
VISUALIZATION_INSTALL_HINT = (
|
|
32
|
+
"Network visualization requires the 'pyvis' optional dependency. "
|
|
33
|
+
'Install via `pip install "cyvest[visualization]"`.'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VisualizationDependencyMissingError(RuntimeError):
|
|
38
|
+
"""Raised when the optional visualization extra is not installed."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(VISUALIZATION_INSTALL_HINT)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Pyvis color mapping from Rich color names
|
|
45
|
+
PYVIS_COLOR_MAP = {
|
|
46
|
+
"white": "#FFFFFF",
|
|
47
|
+
"green": "#00FF00",
|
|
48
|
+
"cyan": "#00FFFF",
|
|
49
|
+
"bright_green": "#90EE90",
|
|
50
|
+
"yellow": "#FFFF00",
|
|
51
|
+
"orange3": "#FFA500",
|
|
52
|
+
"red": "#FF0000",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Observable type to shape mapping for visual distinction
|
|
56
|
+
OBSERVABLE_SHAPES = {
|
|
57
|
+
ObservableType.IPV4: "dot",
|
|
58
|
+
ObservableType.IPV6: "dot",
|
|
59
|
+
ObservableType.DOMAIN: "diamond",
|
|
60
|
+
ObservableType.URL: "diamond",
|
|
61
|
+
ObservableType.EMAIL: "diamond",
|
|
62
|
+
ObservableType.HASH: "square",
|
|
63
|
+
ObservableType.FILE: "square",
|
|
64
|
+
ObservableType.ARTIFACT: "square",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_node_color(level: Level) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Get pyvis-compatible color for a security level.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
level: Security level
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Hex color code for pyvis
|
|
77
|
+
"""
|
|
78
|
+
rich_color = LEVEL_COLORS.get(level, "white")
|
|
79
|
+
return PYVIS_COLOR_MAP.get(rich_color, "#FFFFFF")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_edge_color(direction: RelationshipDirection) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Get edge color based on relationship direction.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
direction: Relationship direction
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Hex color code for edge
|
|
91
|
+
"""
|
|
92
|
+
if direction == RelationshipDirection.OUTBOUND:
|
|
93
|
+
return "#4A90E2" # Blue for outbound
|
|
94
|
+
elif direction == RelationshipDirection.INBOUND:
|
|
95
|
+
return "#E24A90" # Pink for inbound
|
|
96
|
+
else: # BIDIRECTIONAL
|
|
97
|
+
return "#9B59B6" # Purple for bidirectional
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_edge_arrows(direction: RelationshipDirection) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Get arrow configuration for relationship direction.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
direction: Relationship direction
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Arrow configuration string
|
|
109
|
+
"""
|
|
110
|
+
if direction == RelationshipDirection.OUTBOUND:
|
|
111
|
+
return "to"
|
|
112
|
+
elif direction == RelationshipDirection.INBOUND:
|
|
113
|
+
return "from"
|
|
114
|
+
else: # BIDIRECTIONAL
|
|
115
|
+
return "to;from"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def truncate_middle(text: str, max_length: int) -> str:
|
|
119
|
+
"""Truncate text in the middle with an ellipsis when exceeding ``max_length``.
|
|
120
|
+
|
|
121
|
+
Keeps both the start and end of the string visible for context while
|
|
122
|
+
respecting the provided length budget.
|
|
123
|
+
"""
|
|
124
|
+
if max_length < 4 or len(text) <= max_length:
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
reserved = max_length - 3
|
|
128
|
+
head = reserved // 2 + reserved % 2
|
|
129
|
+
tail = reserved - head
|
|
130
|
+
|
|
131
|
+
return f"{text[:head]}...{text[-tail:]}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate_network_graph(
|
|
135
|
+
cv: Cyvest,
|
|
136
|
+
output_dir: str | None = None,
|
|
137
|
+
open_browser: bool = True,
|
|
138
|
+
min_level: Level | None = None,
|
|
139
|
+
observable_types: list[ObservableType] | None = None,
|
|
140
|
+
physics: bool = True,
|
|
141
|
+
group_by_type: bool = False,
|
|
142
|
+
max_label_length: int = 60,
|
|
143
|
+
title: str = "Cyvest Investigation Network",
|
|
144
|
+
) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Generate an interactive network graph visualization of observables and relationships.
|
|
147
|
+
|
|
148
|
+
Creates an HTML file with a pyvis network graph showing observables as nodes
|
|
149
|
+
(colored by level, sized by score, shaped by type) and relationships as edges
|
|
150
|
+
(colored by direction, labeled by type).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
cv: Cyvest investigation to visualize
|
|
154
|
+
output_dir: Directory to save HTML file (defaults to temp directory)
|
|
155
|
+
open_browser: Whether to automatically open the HTML file in a browser
|
|
156
|
+
min_level: Minimum security level to include (filters out lower levels)
|
|
157
|
+
observable_types: List of observable types to include (filters out others)
|
|
158
|
+
physics: Enable physics simulation for organic layout (default: False for static layout)
|
|
159
|
+
group_by_type: Group observables by type using hierarchical layout (default: False)
|
|
160
|
+
max_label_length: Maximum length for node labels before truncation (default: 60)
|
|
161
|
+
title: Title displayed in the HTML visualization
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Path to the generated HTML file
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
>>> from cyvest import Cyvest
|
|
168
|
+
>>> from cyvest.io_visualization import generate_network_graph
|
|
169
|
+
>>> cv = Cyvest()
|
|
170
|
+
>>> # Create investigation with observables
|
|
171
|
+
>>> generate_network_graph(cv)
|
|
172
|
+
'/tmp/cyvest_network_12345.html'
|
|
173
|
+
"""
|
|
174
|
+
if not PYVIS_AVAILABLE or Network is None: # pragma: no branch - both change together
|
|
175
|
+
raise VisualizationDependencyMissingError()
|
|
176
|
+
|
|
177
|
+
normalized_min_level = min_level
|
|
178
|
+
|
|
179
|
+
# Create pyvis network with physics enabled for organic layout
|
|
180
|
+
net = Network(
|
|
181
|
+
height="800px",
|
|
182
|
+
width="100%",
|
|
183
|
+
bgcolor="#FFFFFF",
|
|
184
|
+
font_color="#1E1E1E",
|
|
185
|
+
directed=True,
|
|
186
|
+
)
|
|
187
|
+
net.heading = title
|
|
188
|
+
|
|
189
|
+
# Configure physics and interaction options
|
|
190
|
+
physics_enabled = "true" if physics else "false"
|
|
191
|
+
|
|
192
|
+
# Build layout configuration
|
|
193
|
+
if group_by_type:
|
|
194
|
+
layout_config = """
|
|
195
|
+
"layout": {
|
|
196
|
+
"hierarchical": {
|
|
197
|
+
"enabled": true,
|
|
198
|
+
"direction": "UD",
|
|
199
|
+
"sortMethod": "directed",
|
|
200
|
+
"levelSeparation": 200,
|
|
201
|
+
"nodeSpacing": 150,
|
|
202
|
+
"treeSpacing": 200
|
|
203
|
+
}
|
|
204
|
+
},"""
|
|
205
|
+
else:
|
|
206
|
+
layout_config = ""
|
|
207
|
+
|
|
208
|
+
net.set_options(
|
|
209
|
+
f"""
|
|
210
|
+
{{
|
|
211
|
+
{layout_config}
|
|
212
|
+
"physics": {{
|
|
213
|
+
"enabled": {physics_enabled},
|
|
214
|
+
"stabilization": {{
|
|
215
|
+
"enabled": true,
|
|
216
|
+
"iterations": 200
|
|
217
|
+
}},
|
|
218
|
+
"barnesHut": {{
|
|
219
|
+
"gravitationalConstant": -8000,
|
|
220
|
+
"centralGravity": 0.3,
|
|
221
|
+
"springLength": 95,
|
|
222
|
+
"springConstant": 0.04,
|
|
223
|
+
"damping": 0.09,
|
|
224
|
+
"avoidOverlap": 0.1
|
|
225
|
+
}}
|
|
226
|
+
}},
|
|
227
|
+
"interaction": {{
|
|
228
|
+
"navigationButtons": false,
|
|
229
|
+
"keyboard": true
|
|
230
|
+
}}
|
|
231
|
+
}}
|
|
232
|
+
"""
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Get all observables
|
|
236
|
+
observables = cv.observable_get_all()
|
|
237
|
+
|
|
238
|
+
# Filter observables based on criteria
|
|
239
|
+
filtered_observables = {}
|
|
240
|
+
for key, obs in observables.items():
|
|
241
|
+
# Filter by minimum level
|
|
242
|
+
if normalized_min_level is not None and obs.level < normalized_min_level:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Filter by observable types
|
|
246
|
+
if observable_types is not None and obs.obs_type not in observable_types:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
filtered_observables[key] = obs
|
|
250
|
+
|
|
251
|
+
# Get root observable key for special positioning
|
|
252
|
+
root_key = cv.observable_get_root().key if cv.observable_get_root() else None
|
|
253
|
+
|
|
254
|
+
# Add nodes for each observable
|
|
255
|
+
for key, obs in filtered_observables.items():
|
|
256
|
+
# Get color based on level
|
|
257
|
+
color = get_node_color(obs.level)
|
|
258
|
+
|
|
259
|
+
# Get shape based on observable type
|
|
260
|
+
shape = OBSERVABLE_SHAPES.get(obs.obs_type, "dot")
|
|
261
|
+
|
|
262
|
+
# Build label with type, value, and level
|
|
263
|
+
obs_type_str = obs.obs_type.value if isinstance(obs.obs_type, ObservableType) else str(obs.obs_type)
|
|
264
|
+
obs_value = f"{obs.value}"
|
|
265
|
+
label = truncate_middle(obs_value, max_label_length)
|
|
266
|
+
|
|
267
|
+
# Build title (hover text) with detailed info
|
|
268
|
+
title_parts = [
|
|
269
|
+
f"Type: {obs_type_str}",
|
|
270
|
+
f"Value: {obs.value}",
|
|
271
|
+
f"Level: {obs.level.name}",
|
|
272
|
+
f"Score: {obs.score_display}",
|
|
273
|
+
f"Key: {key}",
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
if obs.internal:
|
|
277
|
+
title_parts.append("Internal: Yes")
|
|
278
|
+
if obs.whitelisted:
|
|
279
|
+
title_parts.append("Whitelisted: Yes")
|
|
280
|
+
if obs.threat_intels:
|
|
281
|
+
title_parts.append(f"Threat Intel Sources: {len(obs.threat_intels)}")
|
|
282
|
+
if obs.check_links:
|
|
283
|
+
title_parts.append(f"Linked checks: {len(obs.check_links)}")
|
|
284
|
+
|
|
285
|
+
title_text = "\n".join(title_parts)
|
|
286
|
+
|
|
287
|
+
# Prepare node options
|
|
288
|
+
node_options = {
|
|
289
|
+
"label": label,
|
|
290
|
+
"title": title_text,
|
|
291
|
+
"color": color,
|
|
292
|
+
"size": 10,
|
|
293
|
+
"shape": shape,
|
|
294
|
+
"font": {"size": 10, "color": "#FFFFFF"},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Add group attribute for type-based grouping
|
|
298
|
+
if group_by_type:
|
|
299
|
+
node_options["group"] = obs.obs_type.value
|
|
300
|
+
|
|
301
|
+
# Fix root node at center position if physics is disabled
|
|
302
|
+
if key == root_key and not physics:
|
|
303
|
+
node_options["label"] = "ROOT"
|
|
304
|
+
node_options["x"] = 0
|
|
305
|
+
node_options["y"] = 0
|
|
306
|
+
node_options["fixed"] = True
|
|
307
|
+
node_options["size"] = 20 # Make root node slightly larger
|
|
308
|
+
|
|
309
|
+
# Add node to network
|
|
310
|
+
net.add_node(key, **node_options)
|
|
311
|
+
|
|
312
|
+
# Add edges for relationships
|
|
313
|
+
for key, obs in filtered_observables.items():
|
|
314
|
+
for rel in obs.relationships:
|
|
315
|
+
# Only add edge if target is also in filtered observables
|
|
316
|
+
if rel.target_key not in filtered_observables:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Get edge properties based on direction
|
|
320
|
+
edge_color = get_edge_color(rel.direction)
|
|
321
|
+
arrows = get_edge_arrows(rel.direction)
|
|
322
|
+
|
|
323
|
+
# Build edge label
|
|
324
|
+
rel_type_str = (
|
|
325
|
+
rel.relationship_type.value
|
|
326
|
+
if isinstance(rel.relationship_type, RelationshipType)
|
|
327
|
+
else str(rel.relationship_type)
|
|
328
|
+
)
|
|
329
|
+
edge_label = rel_type_str
|
|
330
|
+
|
|
331
|
+
# Build edge title (hover text)
|
|
332
|
+
edge_title = f"{rel_type_str}\nDirection: {rel.direction.name}"
|
|
333
|
+
|
|
334
|
+
# Add edge to network
|
|
335
|
+
net.add_edge(
|
|
336
|
+
key,
|
|
337
|
+
rel.target_key,
|
|
338
|
+
label=edge_label,
|
|
339
|
+
title=edge_title,
|
|
340
|
+
color=edge_color,
|
|
341
|
+
arrows=arrows,
|
|
342
|
+
font={"size": 8},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Determine output path
|
|
346
|
+
if output_dir is None:
|
|
347
|
+
output_dir = tempfile.mkdtemp(prefix="cyvest_")
|
|
348
|
+
|
|
349
|
+
output_path = Path(output_dir) / "cyvest_network.html"
|
|
350
|
+
|
|
351
|
+
# Save to HTML file
|
|
352
|
+
net.save_graph(str(output_path))
|
|
353
|
+
|
|
354
|
+
# Open in browser if requested
|
|
355
|
+
if open_browser:
|
|
356
|
+
webbrowser.open(f"file://{output_path.absolute()}")
|
|
357
|
+
|
|
358
|
+
return str(output_path)
|
cyvest/keys.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Key generation utilities for Cyvest objects.
|
|
3
|
+
|
|
4
|
+
Provides deterministic, unique key generation for all object types.
|
|
5
|
+
Keys are used for object identification, retrieval, and merging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_value(value: str) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Normalize a string value for consistent key generation.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
value: The value to normalize
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Normalized lowercase string
|
|
21
|
+
"""
|
|
22
|
+
return value.strip().lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hash_dict(data: dict[str, Any]) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Create a deterministic hash from a dictionary.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: Dictionary to hash
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
SHA256 hash of the sorted dictionary items
|
|
34
|
+
"""
|
|
35
|
+
# Sort keys for deterministic ordering
|
|
36
|
+
sorted_items = sorted(data.items())
|
|
37
|
+
content = str(sorted_items)
|
|
38
|
+
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_observable_key(obs_type: str, value: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Generate a unique key for an observable.
|
|
44
|
+
|
|
45
|
+
Format: obs:{type}:{normalized_value}
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
obs_type: Type of observable (ipv4, ipv6, url, domain, hash, email, etc.)
|
|
49
|
+
value: Value of the observable
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Unique observable key
|
|
53
|
+
"""
|
|
54
|
+
normalized_type = _normalize_value(obs_type)
|
|
55
|
+
normalized_value = _normalize_value(value)
|
|
56
|
+
return f"obs:{normalized_type}:{normalized_value}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def generate_check_key(check_id: str, scope: str) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Generate a unique key for a check.
|
|
62
|
+
|
|
63
|
+
Format: chk:{check_id}:{scope}
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
check_id: Identifier of the check
|
|
67
|
+
scope: Scope of the check
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Unique check key
|
|
71
|
+
"""
|
|
72
|
+
normalized_id = _normalize_value(check_id)
|
|
73
|
+
normalized_scope = _normalize_value(scope)
|
|
74
|
+
return f"chk:{normalized_id}:{normalized_scope}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_threat_intel_key(source: str, observable_key: str) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Generate a unique key for threat intelligence.
|
|
80
|
+
|
|
81
|
+
Format: ti:{normalized_source}:{observable_key}
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
source: Name of the threat intel source
|
|
85
|
+
observable_key: Key of the related observable
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Unique threat intel key
|
|
89
|
+
"""
|
|
90
|
+
normalized_source = _normalize_value(source)
|
|
91
|
+
return f"ti:{normalized_source}:{observable_key}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def generate_enrichment_key(name: str, context: str = "") -> str:
|
|
95
|
+
"""
|
|
96
|
+
Generate a unique key for an enrichment.
|
|
97
|
+
|
|
98
|
+
Format: enr:{name}:{context_hash}
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
name: Name of the enrichment
|
|
102
|
+
context: Optional context string
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Unique enrichment key
|
|
106
|
+
"""
|
|
107
|
+
normalized_name = _normalize_value(name)
|
|
108
|
+
if context:
|
|
109
|
+
context_hash = hashlib.sha256(context.encode()).hexdigest()[:8]
|
|
110
|
+
return f"enr:{normalized_name}:{context_hash}"
|
|
111
|
+
return f"enr:{normalized_name}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate_container_key(path: str) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Generate a unique key for a container.
|
|
117
|
+
|
|
118
|
+
Format: ctr:{normalized_path}
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: Path of the container (can use / or . as separator)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Unique container key
|
|
125
|
+
"""
|
|
126
|
+
# Normalize path separators
|
|
127
|
+
normalized_path = path.replace("\\", "/").strip("/")
|
|
128
|
+
normalized_path = _normalize_value(normalized_path)
|
|
129
|
+
return f"ctr:{normalized_path}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_key_type(key: str) -> str | None:
|
|
133
|
+
"""
|
|
134
|
+
Extract the type prefix from a key.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
key: The key to parse
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Type prefix (obs, chk, ti, enr, ctr) or None if invalid
|
|
141
|
+
"""
|
|
142
|
+
if ":" in key:
|
|
143
|
+
return key.split(":", 1)[0]
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_observable_key(key: str) -> tuple[str, str] | None:
|
|
148
|
+
"""
|
|
149
|
+
Parse an observable key into its type and value.
|
|
150
|
+
|
|
151
|
+
Format: obs:{type}:{normalized_value}
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
key: Observable key to parse
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Tuple of (observable type, value) or None if invalid
|
|
158
|
+
"""
|
|
159
|
+
if parse_key_type(key) != "obs":
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
parts = key.split(":", 2)
|
|
163
|
+
if len(parts) != 3:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
_, obs_type, value = parts
|
|
167
|
+
if not obs_type or not value:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return obs_type, value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_key(key: str, expected_type: str | None = None) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Validate a key format and optionally check its type.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
key: The key to validate
|
|
179
|
+
expected_type: Optional expected type prefix
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if valid, False otherwise
|
|
183
|
+
"""
|
|
184
|
+
if not key or ":" not in key:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
key_type = parse_key_type(key)
|
|
188
|
+
if key_type not in ("obs", "chk", "ti", "enr", "ctr"):
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
if expected_type and key_type != expected_type:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
return True
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Policy rules that map scores/levels to model state changes.
|
|
3
|
+
|
|
4
|
+
This module intentionally holds "rules" (creation defaults, score mutation behavior)
|
|
5
|
+
on top of the pure mapping in ``cyvest.levels.get_level_from_score``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cyvest.levels import Level, get_level_from_score
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _coerce_decimal(value: Decimal | float | int | str | None) -> Decimal | None:
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
if isinstance(value, Decimal):
|
|
20
|
+
return value
|
|
21
|
+
return Decimal(str(value))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_creation_score_level_defaults(
|
|
25
|
+
values: Any,
|
|
26
|
+
*,
|
|
27
|
+
default_level_no_score: Level,
|
|
28
|
+
require_score: bool = False,
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Apply Cyvest score/level creation rules to a pre-validation dict.
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- If ``require_score`` is True, ``score`` must be provided.
|
|
35
|
+
- If ``level`` is provided, keep it (other rules do not apply).
|
|
36
|
+
- Else if ``score`` is provided, set ``level = get_level_from_score(score)``.
|
|
37
|
+
- Else (no score/level provided), set ``level = default_level_no_score``.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(values, dict):
|
|
40
|
+
return values
|
|
41
|
+
|
|
42
|
+
has_score = "score" in values and values.get("score") is not None
|
|
43
|
+
has_level = "level" in values and values.get("level") is not None
|
|
44
|
+
if require_score and not has_score:
|
|
45
|
+
raise ValueError("score is required")
|
|
46
|
+
|
|
47
|
+
score = _coerce_decimal(values.get("score")) if has_score else None
|
|
48
|
+
if score is None:
|
|
49
|
+
if require_score:
|
|
50
|
+
raise ValueError("score is required")
|
|
51
|
+
score = Decimal("0")
|
|
52
|
+
values["score"] = score
|
|
53
|
+
else:
|
|
54
|
+
values["score"] = score
|
|
55
|
+
|
|
56
|
+
if has_level:
|
|
57
|
+
return values
|
|
58
|
+
|
|
59
|
+
if has_score:
|
|
60
|
+
values["level"] = get_level_from_score(score)
|
|
61
|
+
return values
|
|
62
|
+
|
|
63
|
+
values["level"] = default_level_no_score
|
|
64
|
+
return values
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def recalculate_level_for_score(current_level: Level | None, new_score: Decimal) -> Level:
|
|
68
|
+
"""
|
|
69
|
+
Apply Cyvest score -> level rules when a score mutates.
|
|
70
|
+
|
|
71
|
+
Rules:
|
|
72
|
+
- Level is recalculated using ``get_level_from_score``.
|
|
73
|
+
- SAFE is "sticky" against downgrades: keep SAFE unless the new calculated level is greater than SAFE.
|
|
74
|
+
"""
|
|
75
|
+
calculated = get_level_from_score(new_score)
|
|
76
|
+
if current_level == Level.SAFE and calculated <= current_level:
|
|
77
|
+
return current_level
|
|
78
|
+
return calculated
|