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/__init__.py CHANGED
@@ -1,47 +1,57 @@
1
- """Investigation models."""
1
+ """
2
+ Cyvest - Cybersecurity Investigation Framework
2
3
 
3
- __all__: list[str]
4
- __version__ = "0.1.0"
4
+ A Python framework for building, analyzing, and structuring cybersecurity investigations
5
+ programmatically with automatic scoring, level calculation, and rich reporting capabilities.
6
+ """
5
7
 
6
- from .builder import ReportBuilder
7
- from .models import (
8
- MAP_LEVEL_DATA,
9
- ContainableSLM,
10
- Container,
11
- Enrichment,
12
- Level,
13
- Model,
14
- Observable,
15
- ObsType,
16
- ResultCheck,
17
- Scope,
18
- ScoredLevelModel,
19
- ThreatIntel,
20
- get_color_level,
21
- get_color_score,
22
- get_level_from_score,
8
+ from logurich import logger
9
+
10
+ from cyvest.compare import (
11
+ DiffItem,
12
+ DiffStatus,
13
+ ExpectedResult,
14
+ ObservableDiff,
15
+ ThreatIntelDiff,
16
+ compare_investigations,
23
17
  )
24
- from .visitors import Action, Report, Visitor
18
+ from cyvest.cyvest import Cyvest
19
+ from cyvest.levels import Level
20
+ from cyvest.model import Check, Enrichment, InvestigationWhitelist, Observable, Tag, Taxonomy, ThreatIntel
21
+ from cyvest.model_enums import ObservableType, RelationshipDirection, RelationshipType
22
+ from cyvest.proxies import CheckProxy, EnrichmentProxy, ObservableProxy, TagProxy, ThreatIntelProxy
23
+
24
+ __version__ = "5.1.3"
25
+
26
+ logger.disable("cyvest")
25
27
 
26
28
  __all__ = [
27
- "Action",
28
- "Container",
29
- "ContainableSLM",
30
- "Enrichment",
29
+ # Core class
30
+ "Cyvest",
31
+ # Enums
31
32
  "Level",
32
- "MAP_LEVEL_DATA",
33
- "Model",
33
+ "ObservableType",
34
+ "RelationshipDirection",
35
+ "RelationshipType",
36
+ # Proxies
37
+ "CheckProxy",
38
+ "ObservableProxy",
39
+ "ThreatIntelProxy",
40
+ "EnrichmentProxy",
41
+ "TagProxy",
42
+ # Models
43
+ "Tag",
44
+ "Enrichment",
45
+ "InvestigationWhitelist",
46
+ "Check",
34
47
  "Observable",
35
- "ObsType",
36
- "Report",
37
- "ResultCheck",
38
- "Scope",
39
- "ScoredLevelModel",
40
48
  "ThreatIntel",
41
- "Visitor",
42
- "get_color_level",
43
- "get_color_score",
44
- "get_level_from_score",
45
- "ReportBuilder",
46
- "__version__",
49
+ "Taxonomy",
50
+ # Comparison module
51
+ "compare_investigations",
52
+ "ExpectedResult",
53
+ "DiffItem",
54
+ "DiffStatus",
55
+ "ObservableDiff",
56
+ "ThreatIntelDiff",
47
57
  ]
cyvest/cli.py ADDED
@@ -0,0 +1,487 @@
1
+ """
2
+ Click-based command-line interface for Cyvest.
3
+
4
+ Provides commands for managing investigations, displaying summaries,
5
+ and generating simple reports from serialized investigations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+ from logurich import logger
16
+ from logurich.opt_click import click_logger_params
17
+ from rich.console import Console
18
+
19
+ from cyvest import __version__
20
+ from cyvest.compare import ExpectedResult, compare_investigations
21
+ from cyvest.io_rich import display_check_query, display_diff, display_observable_query, display_threat_intel_query
22
+ from cyvest.io_schema import get_investigation_schema
23
+ from cyvest.io_serialization import load_investigation_json
24
+ from cyvest.io_visualization import VisualizationDependencyMissingError
25
+ from cyvest.keys import parse_key_type
26
+
27
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
28
+ console = Console()
29
+
30
+
31
+ def _load_investigation(input_path: Path) -> dict[str, Any]:
32
+ """Load a serialized investigation from disk."""
33
+ with input_path.open("r", encoding="utf-8") as handle:
34
+ return json.load(handle)
35
+
36
+
37
+ def _print_stats_overview(stats: dict[str, Any]) -> None:
38
+ """Render a lightweight overview of statistics."""
39
+ if not stats:
40
+ logger.info(" No statistics available.")
41
+ return
42
+
43
+ for key, value in stats.items():
44
+ if isinstance(value, dict):
45
+ continue
46
+ logger.info(" {}: {}", key, value)
47
+
48
+
49
+ def _write_markdown(data: dict[str, Any], output_path: Path) -> None:
50
+ """Write a basic Markdown report derived from serialized data."""
51
+ stats = data.get("stats", {})
52
+ score_value = data.get("score", None)
53
+ try:
54
+ score_display = "N/A" if score_value is None else f"{float(score_value):.2f}"
55
+ except (TypeError, ValueError):
56
+ score_display = str(score_value)
57
+ lines = [
58
+ "# Investigation Report",
59
+ "",
60
+ f"**Score:** {score_display}",
61
+ f"**Level:** {data.get('level', 'N/A')}",
62
+ "",
63
+ "## Statistics",
64
+ "",
65
+ ]
66
+
67
+ for key, value in stats.items():
68
+ if isinstance(value, dict):
69
+ continue
70
+ lines.append(f"- **{key}:** {value}")
71
+
72
+ output_path.parent.mkdir(parents=True, exist_ok=True)
73
+ output_path.write_text("\n".join(lines), encoding="utf-8")
74
+
75
+
76
+ @click.group(context_settings=CONTEXT_SETTINGS)
77
+ @click_logger_params
78
+ @click.version_option(__version__, prog_name="Cyvest")
79
+ def cli() -> None:
80
+ """Cyvest - Cybersecurity Investigation Framework."""
81
+ logger.enable("cyvest")
82
+ logger.info("> [green bold]CYVEST[/green bold]")
83
+
84
+
85
+ @cli.command()
86
+ @click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
87
+ @click.option("--stats/--no-stats", default=False, help="Display statistics tables after the summary.")
88
+ @click.option(
89
+ "--graph/--no-graph",
90
+ default=True,
91
+ show_default=True,
92
+ help="Toggle observable graph rendering.",
93
+ )
94
+ def show(input: Path, stats: bool, graph: bool) -> None:
95
+ """
96
+ Display an investigation from a JSON file.
97
+ """
98
+ cv = load_investigation_json(input)
99
+ cv.display_summary(show_graph=graph)
100
+
101
+ if stats:
102
+ logger.info("")
103
+ cv.display_statistics()
104
+
105
+
106
+ @cli.command()
107
+ @click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
108
+ @click.option("-d", "--detailed", is_flag=True, help="Show detailed breakdowns.")
109
+ def stats(input: Path, detailed: bool) -> None:
110
+ """
111
+ Display statistics for an investigation.
112
+ """
113
+
114
+ cv = load_investigation_json(input)
115
+ logger.info(f"[cyan]Statistics for: {input}[/cyan]")
116
+ logger.info("[bold]Overview:[/bold]")
117
+ logger.info(" Global Score: {}", f"{cv.get_global_score():.2f}")
118
+ logger.info(" Global Level: {}", cv.get_global_level())
119
+
120
+ if detailed:
121
+ logger.info("")
122
+ cv.display_statistics()
123
+ else:
124
+ _print_stats_overview(cv.get_statistics())
125
+
126
+
127
+ @cli.command()
128
+ @click.argument("inputs", nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
129
+ @click.option("-o", "--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
130
+ @click.option(
131
+ "-f",
132
+ "--format",
133
+ "output_format",
134
+ type=click.Choice(["json", "rich"], case_sensitive=False),
135
+ default="json",
136
+ show_default=True,
137
+ help="Output format for merged investigation.",
138
+ )
139
+ @click.option(
140
+ "--stats/--no-stats",
141
+ default=True,
142
+ show_default=True,
143
+ help="Display merge statistics after merging.",
144
+ )
145
+ def merge(inputs: tuple[Path, ...], output: Path, output_format: str, stats: bool) -> None:
146
+ """
147
+ Merge multiple investigation JSON files into a single investigation.
148
+
149
+ This command loads multiple investigation files and merges them together,
150
+ automatically handling duplicate objects and score propagation.
151
+ The merged investigation is saved to the specified output file.
152
+ """
153
+ if len(inputs) < 2:
154
+ raise click.BadParameter("Provide at least two input files.", param_hint="inputs")
155
+
156
+ logger.info(f"[cyan]Merging {len(inputs)} investigation files...[/cyan]")
157
+
158
+ # Load first investigation
159
+ logger.info(f" Loading: {inputs[0]}")
160
+ main_investigation = load_investigation_json(inputs[0])
161
+
162
+ # Merge all other investigations
163
+ for input_path in inputs[1:]:
164
+ logger.info(f" Loading: {input_path}")
165
+ other_investigation = load_investigation_json(input_path)
166
+ logger.info(f" Merging: {input_path.name}")
167
+ main_investigation.merge_investigation(other_investigation)
168
+
169
+ logger.info("[green]✓ Merge complete[/green]\n")
170
+
171
+ # Display statistics if requested
172
+ if stats:
173
+ logger.info("[bold]Merged Investigation Statistics:[/bold]")
174
+ investigation_stats = main_investigation.get_statistics()
175
+ logger.info(f" Total Observables: {investigation_stats.total_observables}")
176
+ logger.info(f" Total Checks: {investigation_stats.total_checks}")
177
+ logger.info(f" Total Threat Intel: {investigation_stats.total_threat_intel}")
178
+ logger.info(f" Total Tags: {investigation_stats.total_tags}")
179
+ logger.info(f" Global Score: {main_investigation.get_global_score():.2f}")
180
+ logger.info(f" Global Level: {main_investigation.get_global_level()}\n")
181
+
182
+ # Save merged investigation
183
+ output_path = output.resolve()
184
+ output_path.parent.mkdir(parents=True, exist_ok=True)
185
+
186
+ if output_format == "json":
187
+ main_investigation.io_save_json(str(output_path))
188
+ logger.info(f"[green]✓ Saved merged investigation to: {output_path}[/green]")
189
+ elif output_format == "rich":
190
+ # Display rich summary
191
+ logger.info("[bold]Merged Investigation Summary:[/bold]\n")
192
+ main_investigation.display_summary(show_graph=True)
193
+ # Also save as JSON
194
+ json_output = output_path.with_suffix(".json")
195
+ main_investigation.io_save_json(str(json_output))
196
+ logger.info(f"\n[green]✓ Saved merged investigation to: {json_output}[/green]")
197
+
198
+
199
+ @cli.command()
200
+ @click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
201
+ @click.option("-o", "--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
202
+ @click.option(
203
+ "-f",
204
+ "--format",
205
+ "export_format",
206
+ type=click.Choice(["json", "markdown"], case_sensitive=False),
207
+ default="markdown",
208
+ show_default=True,
209
+ help="Output format.",
210
+ )
211
+ def export(input: Path, output: Path, export_format: str) -> None:
212
+ """
213
+ Export an investigation to a different format.
214
+ """
215
+
216
+ data = _load_investigation(input)
217
+ output_path = output.resolve()
218
+ output_path.parent.mkdir(parents=True, exist_ok=True)
219
+
220
+ if export_format.lower() == "json":
221
+ with output_path.open("w", encoding="utf-8") as handle:
222
+ json.dump(data, handle, indent=2, ensure_ascii=False)
223
+ logger.info(f"[green]Exported to JSON: {output_path}[/green]")
224
+ return
225
+
226
+ _write_markdown(data, output_path)
227
+ logger.info(f"[green]Exported to Markdown: {output_path}[/green]")
228
+
229
+
230
+ @cli.command(name="schema")
231
+ @click.option(
232
+ "-o",
233
+ "--output",
234
+ type=click.Path(dir_okay=False, path_type=Path),
235
+ help="Write the JSON Schema to a file instead of stdout.",
236
+ )
237
+ def schema_cmd(output: Path | None) -> None:
238
+ """
239
+ Emit the JSON Schema describing serialized investigations.
240
+ """
241
+ schema = get_investigation_schema()
242
+ if output:
243
+ output_path = output.resolve()
244
+ output_path.parent.mkdir(parents=True, exist_ok=True)
245
+ output_path.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8")
246
+ logger.info(f"[green]Schema written to: {output_path}[/green]")
247
+ return
248
+
249
+ logger.rich("INFO", json.dumps(schema, indent=2), prefix=False)
250
+
251
+
252
+ @cli.command()
253
+ @click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
254
+ @click.option(
255
+ "--output-dir",
256
+ type=click.Path(file_okay=False, path_type=Path),
257
+ help="Directory to save HTML file (defaults to temporary directory).",
258
+ )
259
+ @click.option(
260
+ "--no-browser",
261
+ is_flag=True,
262
+ help="Do not automatically open the visualization in a browser.",
263
+ )
264
+ @click.option(
265
+ "--min-level",
266
+ type=click.Choice(["TRUSTED", "INFO", "SAFE", "NOTABLE", "SUSPICIOUS", "MALICIOUS"], case_sensitive=False),
267
+ help="Minimum security level to include in the visualization.",
268
+ )
269
+ @click.option(
270
+ "--types",
271
+ help="Comma-separated list of observable types to include (e.g., 'ipv4,domain,url').",
272
+ )
273
+ @click.option(
274
+ "--title",
275
+ default="Cyvest Investigation Network",
276
+ show_default=True,
277
+ help="Title for the network graph.",
278
+ )
279
+ @click.option(
280
+ "--physics",
281
+ is_flag=True,
282
+ help="Enable physics simulation for organic layout (default: static layout).",
283
+ )
284
+ @click.option(
285
+ "--group-by-type",
286
+ is_flag=True,
287
+ help="Group observables by type using hierarchical layout.",
288
+ )
289
+ def visualize(
290
+ input: Path,
291
+ output_dir: Path | None,
292
+ no_browser: bool,
293
+ min_level: str | None,
294
+ types: str | None,
295
+ title: str,
296
+ physics: bool,
297
+ group_by_type: bool,
298
+ ) -> None:
299
+ """
300
+ Generate an interactive network graph visualization of an investigation.
301
+
302
+ This command creates an HTML file with a pyvis network graph showing
303
+ observables as nodes (colored by level, sized by score, shaped by type)
304
+ and relationships as edges (colored by direction, labeled by type).
305
+
306
+ The visualization is saved to a temporary directory by default, or to
307
+ the specified output directory. The HTML file automatically opens in
308
+ your default browser unless --no-browser is specified.
309
+ """
310
+ from cyvest.levels import Level
311
+ from cyvest.model_enums import ObservableType
312
+
313
+ cv = load_investigation_json(input)
314
+
315
+ # Parse min_level if provided
316
+ min_level_enum = None
317
+ if min_level is not None:
318
+ min_level_enum = Level[min_level.upper()]
319
+
320
+ # Parse observable types if provided
321
+ observable_types = None
322
+ if types is not None:
323
+ parsed_types: list[ObservableType] = []
324
+ for token in types.split(","):
325
+ token = token.strip()
326
+ if not token:
327
+ continue
328
+ try:
329
+ parsed_types.append(ObservableType(token.lower()))
330
+ except ValueError:
331
+ try:
332
+ parsed_types.append(ObservableType[token.upper()])
333
+ except KeyError as exc:
334
+ raise click.ClickException(f"Unknown observable type: {token}") from exc
335
+ observable_types = parsed_types or None
336
+
337
+ # Convert output_dir to string if provided
338
+ output_dir_str = str(output_dir.resolve()) if output_dir is not None else None
339
+
340
+ # Generate visualization
341
+ logger.info(f"[cyan]Generating network visualization for: {input}[/cyan]")
342
+
343
+ try:
344
+ html_path = cv.display_network(
345
+ output_dir=output_dir_str,
346
+ open_browser=not no_browser,
347
+ min_level=min_level_enum,
348
+ observable_types=observable_types,
349
+ title=title,
350
+ physics=physics,
351
+ group_by_type=group_by_type,
352
+ )
353
+ except VisualizationDependencyMissingError as exc:
354
+ raise click.ClickException(str(exc)) from exc
355
+
356
+ logger.info(f"[green]✓ Visualization saved to: {html_path}[/green]")
357
+
358
+ if not no_browser:
359
+ logger.info("[cyan]Opening visualization in browser...[/cyan]")
360
+
361
+
362
+ @cli.command()
363
+ @click.argument("actual", type=click.Path(exists=True, dir_okay=False, path_type=Path))
364
+ @click.argument("expected", type=click.Path(exists=True, dir_okay=False, path_type=Path))
365
+ @click.option(
366
+ "-r",
367
+ "--rules",
368
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
369
+ help="JSON file with ExpectedResult tolerance rules.",
370
+ )
371
+ @click.option(
372
+ "--title",
373
+ default="Investigation Diff",
374
+ show_default=True,
375
+ help="Title for the diff table.",
376
+ )
377
+ def diff(actual: Path, expected: Path, rules: Path | None, title: str) -> None:
378
+ """
379
+ Compare two investigation JSON files and display differences.
380
+
381
+ ACTUAL is the investigation to validate (actual results).
382
+ EXPECTED is the reference investigation (expected results).
383
+
384
+ Optionally provide a rules file with tolerance rules in JSON format:
385
+
386
+ \b
387
+ [
388
+ {"check_name": "domain-check", "score": ">= 1.0"},
389
+ {"key": "chk:ai-analysis", "level": "SUSPICIOUS", "score": "< 3.0"}
390
+ ]
391
+
392
+ Supported operators: >=, <=, >, <, ==, !=
393
+ """
394
+ logger.info("[cyan]Comparing investigations...[/cyan]")
395
+ logger.info(f" Actual: {actual}")
396
+ logger.info(f" Expected: {expected}")
397
+
398
+ actual_cv = load_investigation_json(actual)
399
+ expected_cv = load_investigation_json(expected)
400
+
401
+ # Load tolerance rules if provided
402
+ result_expected: list[ExpectedResult] | None = None
403
+ if rules:
404
+ logger.info(f" Rules: {rules}")
405
+ with rules.open("r", encoding="utf-8") as f:
406
+ rules_data = json.load(f)
407
+ result_expected = [ExpectedResult(**r) for r in rules_data]
408
+
409
+ logger.info("")
410
+
411
+ # Compare investigations
412
+ diffs = compare_investigations(actual_cv, expected_cv, result_expected=result_expected)
413
+
414
+ if not diffs:
415
+ logger.info("[green]✓ No differences found[/green]")
416
+ return
417
+
418
+ # Display diff table
419
+ display_diff(diffs, lambda r: logger.rich("INFO", r, width=150), title=title)
420
+
421
+
422
+ @cli.command()
423
+ @click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
424
+ @click.option(
425
+ "-k",
426
+ "--key",
427
+ required=True,
428
+ help="Key of the object to query (chk:..., obs:..., or ti:...).",
429
+ )
430
+ @click.option(
431
+ "-d",
432
+ "--depth",
433
+ type=int,
434
+ default=1,
435
+ show_default=True,
436
+ help="Relationship traversal depth for observable queries.",
437
+ )
438
+ def query(input: Path, key: str, depth: int) -> None:
439
+ """
440
+ Query a specific object from an investigation file by its key.
441
+
442
+ Displays detailed information about the object, including linked
443
+ objects, scores, levels, and how scores were calculated.
444
+
445
+ \b
446
+ Supports querying:
447
+ - Checks: --key chk:check-name
448
+ - Observables: --key obs:type:value
449
+ - Threat Intel: --key ti:source:obs:type:value
450
+
451
+ \b
452
+ Examples:
453
+ cyvest query investigation.json --key chk:dns-check
454
+ cyvest query investigation.json --key obs:domain:example.com --depth 2
455
+ cyvest query investigation.json -k ti:virustotal:obs:domain:example.com
456
+ """
457
+ cv = load_investigation_json(input)
458
+
459
+ # Determine key type
460
+ key_type = parse_key_type(key)
461
+
462
+ if key_type is None or key_type not in ("chk", "obs", "ti"):
463
+ raise click.ClickException(f"Invalid key format: '{key}'. Expected chk:..., obs:..., or ti:...")
464
+
465
+ logger.info(f"[cyan]Querying: {key}[/cyan]\n")
466
+
467
+ def rich_print(r):
468
+ return logger.rich("INFO", r, prefix=False)
469
+
470
+ try:
471
+ if key_type == "chk":
472
+ display_check_query(cv, key, rich_print)
473
+ elif key_type == "obs":
474
+ display_observable_query(cv, key, rich_print, depth=depth)
475
+ elif key_type == "ti":
476
+ display_threat_intel_query(cv, key, rich_print)
477
+ except KeyError as exc:
478
+ raise click.ClickException(str(exc)) from exc
479
+
480
+
481
+ def main() -> None:
482
+ """Entry point used by the console script."""
483
+ cli()
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()