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
cyvest/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cyvest - Cybersecurity Investigation Framework
|
|
3
|
+
|
|
4
|
+
A Python framework for building, analyzing, and structuring cybersecurity investigations
|
|
5
|
+
programmatically with automatic scoring, level calculation, and rich reporting capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from logurich import logger
|
|
9
|
+
|
|
10
|
+
from cyvest.cyvest import Cyvest
|
|
11
|
+
from cyvest.levels import Level
|
|
12
|
+
from cyvest.model import Check, Container, Enrichment, InvestigationWhitelist, Observable, Taxonomy, ThreatIntel
|
|
13
|
+
from cyvest.model_enums import ObservableType, RelationshipDirection, RelationshipType
|
|
14
|
+
from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
|
|
15
|
+
|
|
16
|
+
__version__ = "4.4.0"
|
|
17
|
+
|
|
18
|
+
logger.disable("cyvest")
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Cyvest",
|
|
22
|
+
"Level",
|
|
23
|
+
"ObservableType",
|
|
24
|
+
"RelationshipDirection",
|
|
25
|
+
"RelationshipType",
|
|
26
|
+
"CheckProxy",
|
|
27
|
+
"ObservableProxy",
|
|
28
|
+
"ThreatIntelProxy",
|
|
29
|
+
"EnrichmentProxy",
|
|
30
|
+
"ContainerProxy",
|
|
31
|
+
"Container",
|
|
32
|
+
"Enrichment",
|
|
33
|
+
"InvestigationWhitelist",
|
|
34
|
+
"Check",
|
|
35
|
+
"Observable",
|
|
36
|
+
"ThreatIntel",
|
|
37
|
+
"Taxonomy",
|
|
38
|
+
]
|
cyvest/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
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.io_schema import get_investigation_schema
|
|
21
|
+
from cyvest.io_serialization import load_investigation_json
|
|
22
|
+
from cyvest.io_visualization import VisualizationDependencyMissingError
|
|
23
|
+
|
|
24
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_investigation(input_path: Path) -> dict[str, Any]:
|
|
29
|
+
"""Load a serialized investigation from disk."""
|
|
30
|
+
with input_path.open("r", encoding="utf-8") as handle:
|
|
31
|
+
return json.load(handle)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print_stats_overview(stats: dict[str, Any]) -> None:
|
|
35
|
+
"""Render a lightweight overview of statistics."""
|
|
36
|
+
if not stats:
|
|
37
|
+
logger.info(" No statistics available.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
for key, value in stats.items():
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
continue
|
|
43
|
+
logger.info(" {}: {}", key, value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_markdown(data: dict[str, Any], output_path: Path) -> None:
|
|
47
|
+
"""Write a basic Markdown report derived from serialized data."""
|
|
48
|
+
stats = data.get("stats", {})
|
|
49
|
+
score_value = data.get("score", None)
|
|
50
|
+
try:
|
|
51
|
+
score_display = "N/A" if score_value is None else f"{float(score_value):.2f}"
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
score_display = str(score_value)
|
|
54
|
+
lines = [
|
|
55
|
+
"# Investigation Report",
|
|
56
|
+
"",
|
|
57
|
+
f"**Score:** {score_display}",
|
|
58
|
+
f"**Level:** {data.get('level', 'N/A')}",
|
|
59
|
+
"",
|
|
60
|
+
"## Statistics",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
for key, value in stats.items():
|
|
65
|
+
if isinstance(value, dict):
|
|
66
|
+
continue
|
|
67
|
+
lines.append(f"- **{key}:** {value}")
|
|
68
|
+
|
|
69
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
74
|
+
@click_logger_params
|
|
75
|
+
@click.version_option(__version__, prog_name="Cyvest")
|
|
76
|
+
def cli() -> None:
|
|
77
|
+
"""Cyvest - Cybersecurity Investigation Framework."""
|
|
78
|
+
logger.enable("cyvest")
|
|
79
|
+
logger.info("> [green bold]CYVEST[/green bold]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cli.command()
|
|
83
|
+
@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
84
|
+
@click.option("--stats/--no-stats", default=False, help="Display statistics tables after the summary.")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--graph/--no-graph",
|
|
87
|
+
default=True,
|
|
88
|
+
show_default=True,
|
|
89
|
+
help="Toggle observable graph rendering.",
|
|
90
|
+
)
|
|
91
|
+
def show(input: Path, stats: bool, graph: bool) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Display an investigation from a JSON file.
|
|
94
|
+
"""
|
|
95
|
+
cv = load_investigation_json(input)
|
|
96
|
+
cv.display_summary(show_graph=graph)
|
|
97
|
+
|
|
98
|
+
if stats:
|
|
99
|
+
logger.info("")
|
|
100
|
+
cv.display_statistics()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@cli.command()
|
|
104
|
+
@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
105
|
+
@click.option("-d", "--detailed", is_flag=True, help="Show detailed breakdowns.")
|
|
106
|
+
def stats(input: Path, detailed: bool) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Display statistics for an investigation.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
cv = load_investigation_json(input)
|
|
112
|
+
logger.info(f"[cyan]Statistics for: {input}[/cyan]")
|
|
113
|
+
logger.info("[bold]Overview:[/bold]")
|
|
114
|
+
logger.info(" Global Score: {}", f"{cv.get_global_score():.2f}")
|
|
115
|
+
logger.info(" Global Level: {}", cv.get_global_level())
|
|
116
|
+
|
|
117
|
+
if detailed:
|
|
118
|
+
logger.info("")
|
|
119
|
+
cv.display_statistics()
|
|
120
|
+
else:
|
|
121
|
+
_print_stats_overview(cv.get_statistics())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@cli.command()
|
|
125
|
+
@click.argument("inputs", nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
126
|
+
@click.option("-o", "--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
|
|
127
|
+
@click.option(
|
|
128
|
+
"-f",
|
|
129
|
+
"--format",
|
|
130
|
+
"output_format",
|
|
131
|
+
type=click.Choice(["json", "rich"], case_sensitive=False),
|
|
132
|
+
default="json",
|
|
133
|
+
show_default=True,
|
|
134
|
+
help="Output format for merged investigation.",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--stats/--no-stats",
|
|
138
|
+
default=True,
|
|
139
|
+
show_default=True,
|
|
140
|
+
help="Display merge statistics after merging.",
|
|
141
|
+
)
|
|
142
|
+
def merge(inputs: tuple[Path, ...], output: Path, output_format: str, stats: bool) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Merge multiple investigation JSON files into a single investigation.
|
|
145
|
+
|
|
146
|
+
This command loads multiple investigation files and merges them together,
|
|
147
|
+
automatically handling duplicate objects and score propagation.
|
|
148
|
+
The merged investigation is saved to the specified output file.
|
|
149
|
+
"""
|
|
150
|
+
if len(inputs) < 2:
|
|
151
|
+
raise click.BadParameter("Provide at least two input files.", param_hint="inputs")
|
|
152
|
+
|
|
153
|
+
logger.info(f"[cyan]Merging {len(inputs)} investigation files...[/cyan]")
|
|
154
|
+
|
|
155
|
+
# Load first investigation
|
|
156
|
+
logger.info(f" Loading: {inputs[0]}")
|
|
157
|
+
main_investigation = load_investigation_json(inputs[0])
|
|
158
|
+
|
|
159
|
+
# Merge all other investigations
|
|
160
|
+
for input_path in inputs[1:]:
|
|
161
|
+
logger.info(f" Loading: {input_path}")
|
|
162
|
+
other_investigation = load_investigation_json(input_path)
|
|
163
|
+
logger.info(f" Merging: {input_path.name}")
|
|
164
|
+
main_investigation.merge_investigation(other_investigation)
|
|
165
|
+
|
|
166
|
+
logger.info("[green]✓ Merge complete[/green]\n")
|
|
167
|
+
|
|
168
|
+
# Display statistics if requested
|
|
169
|
+
if stats:
|
|
170
|
+
logger.info("[bold]Merged Investigation Statistics:[/bold]")
|
|
171
|
+
investigation_stats = main_investigation.get_statistics()
|
|
172
|
+
logger.info(f" Total Observables: {investigation_stats.total_observables}")
|
|
173
|
+
logger.info(f" Total Checks: {investigation_stats.total_checks}")
|
|
174
|
+
logger.info(f" Total Threat Intel: {investigation_stats.total_threat_intel}")
|
|
175
|
+
logger.info(f" Total Containers: {investigation_stats.total_containers}")
|
|
176
|
+
logger.info(f" Global Score: {main_investigation.get_global_score():.2f}")
|
|
177
|
+
logger.info(f" Global Level: {main_investigation.get_global_level()}\n")
|
|
178
|
+
|
|
179
|
+
# Save merged investigation
|
|
180
|
+
output_path = output.resolve()
|
|
181
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
if output_format == "json":
|
|
184
|
+
main_investigation.io_save_json(str(output_path))
|
|
185
|
+
logger.info(f"[green]✓ Saved merged investigation to: {output_path}[/green]")
|
|
186
|
+
elif output_format == "rich":
|
|
187
|
+
# Display rich summary
|
|
188
|
+
logger.info("[bold]Merged Investigation Summary:[/bold]\n")
|
|
189
|
+
main_investigation.display_summary(show_graph=True)
|
|
190
|
+
# Also save as JSON
|
|
191
|
+
json_output = output_path.with_suffix(".json")
|
|
192
|
+
main_investigation.io_save_json(str(json_output))
|
|
193
|
+
logger.info(f"\n[green]✓ Saved merged investigation to: {json_output}[/green]")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
198
|
+
@click.option("-o", "--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
|
|
199
|
+
@click.option(
|
|
200
|
+
"-f",
|
|
201
|
+
"--format",
|
|
202
|
+
"export_format",
|
|
203
|
+
type=click.Choice(["json", "markdown"], case_sensitive=False),
|
|
204
|
+
default="markdown",
|
|
205
|
+
show_default=True,
|
|
206
|
+
help="Output format.",
|
|
207
|
+
)
|
|
208
|
+
def export(input: Path, output: Path, export_format: str) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Export an investigation to a different format.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
data = _load_investigation(input)
|
|
214
|
+
output_path = output.resolve()
|
|
215
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
if export_format.lower() == "json":
|
|
218
|
+
with output_path.open("w", encoding="utf-8") as handle:
|
|
219
|
+
json.dump(data, handle, indent=2, ensure_ascii=False)
|
|
220
|
+
logger.info(f"[green]Exported to JSON: {output_path}[/green]")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
_write_markdown(data, output_path)
|
|
224
|
+
logger.info(f"[green]Exported to Markdown: {output_path}[/green]")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@cli.command(name="schema")
|
|
228
|
+
@click.option(
|
|
229
|
+
"-o",
|
|
230
|
+
"--output",
|
|
231
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
232
|
+
help="Write the JSON Schema to a file instead of stdout.",
|
|
233
|
+
)
|
|
234
|
+
def schema_cmd(output: Path | None) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Emit the JSON Schema describing serialized investigations.
|
|
237
|
+
"""
|
|
238
|
+
schema = get_investigation_schema()
|
|
239
|
+
if output:
|
|
240
|
+
output_path = output.resolve()
|
|
241
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
output_path.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8")
|
|
243
|
+
logger.info(f"[green]Schema written to: {output_path}[/green]")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
logger.rich("INFO", json.dumps(schema, indent=2), prefix=False)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cli.command()
|
|
250
|
+
@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
251
|
+
@click.option(
|
|
252
|
+
"--output-dir",
|
|
253
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
254
|
+
help="Directory to save HTML file (defaults to temporary directory).",
|
|
255
|
+
)
|
|
256
|
+
@click.option(
|
|
257
|
+
"--no-browser",
|
|
258
|
+
is_flag=True,
|
|
259
|
+
help="Do not automatically open the visualization in a browser.",
|
|
260
|
+
)
|
|
261
|
+
@click.option(
|
|
262
|
+
"--min-level",
|
|
263
|
+
type=click.Choice(["TRUSTED", "INFO", "SAFE", "NOTABLE", "SUSPICIOUS", "MALICIOUS"], case_sensitive=False),
|
|
264
|
+
help="Minimum security level to include in the visualization.",
|
|
265
|
+
)
|
|
266
|
+
@click.option(
|
|
267
|
+
"--types",
|
|
268
|
+
help="Comma-separated list of observable types to include (e.g., 'ipv4,domain,url').",
|
|
269
|
+
)
|
|
270
|
+
@click.option(
|
|
271
|
+
"--title",
|
|
272
|
+
default="Cyvest Investigation Network",
|
|
273
|
+
show_default=True,
|
|
274
|
+
help="Title for the network graph.",
|
|
275
|
+
)
|
|
276
|
+
@click.option(
|
|
277
|
+
"--physics",
|
|
278
|
+
is_flag=True,
|
|
279
|
+
help="Enable physics simulation for organic layout (default: static layout).",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--group-by-type",
|
|
283
|
+
is_flag=True,
|
|
284
|
+
help="Group observables by type using hierarchical layout.",
|
|
285
|
+
)
|
|
286
|
+
def visualize(
|
|
287
|
+
input: Path,
|
|
288
|
+
output_dir: Path | None,
|
|
289
|
+
no_browser: bool,
|
|
290
|
+
min_level: str | None,
|
|
291
|
+
types: str | None,
|
|
292
|
+
title: str,
|
|
293
|
+
physics: bool,
|
|
294
|
+
group_by_type: bool,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Generate an interactive network graph visualization of an investigation.
|
|
298
|
+
|
|
299
|
+
This command creates an HTML file with a pyvis network graph showing
|
|
300
|
+
observables as nodes (colored by level, sized by score, shaped by type)
|
|
301
|
+
and relationships as edges (colored by direction, labeled by type).
|
|
302
|
+
|
|
303
|
+
The visualization is saved to a temporary directory by default, or to
|
|
304
|
+
the specified output directory. The HTML file automatically opens in
|
|
305
|
+
your default browser unless --no-browser is specified.
|
|
306
|
+
"""
|
|
307
|
+
from cyvest.levels import Level
|
|
308
|
+
from cyvest.model_enums import ObservableType
|
|
309
|
+
|
|
310
|
+
cv = load_investigation_json(input)
|
|
311
|
+
|
|
312
|
+
# Parse min_level if provided
|
|
313
|
+
min_level_enum = None
|
|
314
|
+
if min_level is not None:
|
|
315
|
+
min_level_enum = Level[min_level.upper()]
|
|
316
|
+
|
|
317
|
+
# Parse observable types if provided
|
|
318
|
+
observable_types = None
|
|
319
|
+
if types is not None:
|
|
320
|
+
parsed_types: list[ObservableType] = []
|
|
321
|
+
for token in types.split(","):
|
|
322
|
+
token = token.strip()
|
|
323
|
+
if not token:
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
parsed_types.append(ObservableType(token.lower()))
|
|
327
|
+
except ValueError:
|
|
328
|
+
try:
|
|
329
|
+
parsed_types.append(ObservableType[token.upper()])
|
|
330
|
+
except KeyError as exc:
|
|
331
|
+
raise click.ClickException(f"Unknown observable type: {token}") from exc
|
|
332
|
+
observable_types = parsed_types or None
|
|
333
|
+
|
|
334
|
+
# Convert output_dir to string if provided
|
|
335
|
+
output_dir_str = str(output_dir.resolve()) if output_dir is not None else None
|
|
336
|
+
|
|
337
|
+
# Generate visualization
|
|
338
|
+
logger.info(f"[cyan]Generating network visualization for: {input}[/cyan]")
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
html_path = cv.display_network(
|
|
342
|
+
output_dir=output_dir_str,
|
|
343
|
+
open_browser=not no_browser,
|
|
344
|
+
min_level=min_level_enum,
|
|
345
|
+
observable_types=observable_types,
|
|
346
|
+
title=title,
|
|
347
|
+
physics=physics,
|
|
348
|
+
group_by_type=group_by_type,
|
|
349
|
+
)
|
|
350
|
+
except VisualizationDependencyMissingError as exc:
|
|
351
|
+
raise click.ClickException(str(exc)) from exc
|
|
352
|
+
|
|
353
|
+
logger.info(f"[green]✓ Visualization saved to: {html_path}[/green]")
|
|
354
|
+
|
|
355
|
+
if not no_browser:
|
|
356
|
+
logger.info("[cyan]Opening visualization in browser...[/cyan]")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main() -> None:
|
|
360
|
+
"""Entry point used by the console script."""
|
|
361
|
+
cli()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|