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.

@@ -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