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/visitors.py DELETED
@@ -1,332 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import copy
4
- from abc import ABC, abstractmethod
5
- from typing import Any
6
-
7
- from logurich import logger
8
-
9
- from .check_tree import CheckTree
10
- from .models import (
11
- Container,
12
- Enrichment,
13
- Level,
14
- Model,
15
- Observable,
16
- ObsType,
17
- ResultCheck,
18
- Scope,
19
- ThreatIntel,
20
- get_level_from_score,
21
- )
22
- from .observable_registry import ObservableRegistry
23
- from .report_render import markdown_summary, stdout_from_json
24
- from .report_serialization import reduce_report_json, report_to_json
25
-
26
-
27
- class Visitor(ABC):
28
- def __init__(self, *, graph: bool = False) -> None:
29
- super().__init__()
30
- self.graph = graph
31
- self._stats = {}
32
-
33
- @property
34
- def stats(self):
35
- return copy.deepcopy(self._stats)
36
-
37
- def init_stat(self, key: str, value: int = 0) -> None:
38
- self._stats[key] = value
39
-
40
- def increment_stat(self, key: str, value: int = 1) -> None:
41
- self._stats[key] += value
42
-
43
- def decrement_stat(self, key: str, value: int = 1) -> None:
44
- self._stats[key] -= value
45
-
46
- def reduce_stats(self, stats: dict) -> dict:
47
- for k, v in self._stats.items():
48
- if not isinstance(v, int):
49
- raise Exception("Reduce Stats can reduce only integers")
50
- if stats.get(k):
51
- stats[k] += v
52
- else:
53
- stats[k] = v
54
- return stats
55
-
56
- @abstractmethod
57
- def visit_threat_intel(self, threat_intel: ThreatIntel) -> ThreatIntel:
58
- """Method that returns models"""
59
- raise NotImplementedError("missing method check")
60
-
61
- @abstractmethod
62
- def visit_observable(self, observable: Observable) -> Observable:
63
- """Method that visit an observable"""
64
- raise NotImplementedError("missing method visit_observable")
65
-
66
- @abstractmethod
67
- def visit_result_check(self, result_check: ResultCheck) -> ResultCheck:
68
- """Method that visit a result entry"""
69
- raise NotImplementedError("missing method visit_result_check")
70
-
71
- @abstractmethod
72
- def visit_container(self, container: Container) -> Container:
73
- """Method that visit a result entry"""
74
- raise NotImplementedError("missing method visit_container")
75
-
76
- @abstractmethod
77
- def visit_enrichment(self, enrichment: Enrichment) -> Enrichment:
78
- """Method that visit an enrichment"""
79
- raise NotImplementedError("missing method visit_enrichment")
80
-
81
- @abstractmethod
82
- def to_json(self):
83
- """Method that convert the object to json"""
84
- raise NotImplementedError("missing method to_json")
85
-
86
- @abstractmethod
87
- def get_check(self, path: str):
88
- """Method that convert the object to json"""
89
- raise NotImplementedError("missing method get_check")
90
-
91
-
92
- class Report(Visitor):
93
- map_stats_suspicious = {
94
- ObsType.IP.name: "ips",
95
- ObsType.URL.name: "urls",
96
- ObsType.DOMAIN.name: "domains",
97
- ObsType.FILE.name: "files",
98
- Scope.FULL.name: "full",
99
- Scope.HEADER.name: "header",
100
- Scope.BODY.name: "body",
101
- Scope.ATTACHMENT.name: "attachment",
102
- "threat_intel": "threat_intels",
103
- }
104
-
105
- def __init__(
106
- self,
107
- json_structure: dict[str, Any] | None = None,
108
- linked_reports: dict[str, Report] | None = None,
109
- *,
110
- graph: bool = False,
111
- ) -> None:
112
- if linked_reports is None:
113
- linked_reports = {}
114
- super().__init__(graph=graph)
115
- payload = copy.deepcopy(json_structure) if json_structure is not None else self.default_payload()
116
- self.json = payload
117
- self.linked_reports = linked_reports
118
- self._seen_stats = {}
119
- self.tree = CheckTree()
120
- self.observable_registry = ObservableRegistry()
121
- self.observables = self.observable_registry._observables
122
- self.enrichments = []
123
- self.global_score = 0.0
124
- self.global_level = get_level_from_score(self.global_score) or Level.INFO
125
- self.annotations: list[dict[str, str]] = []
126
- # init statistics
127
- for type_data, key in Report.map_stats_suspicious.items():
128
- self.init_stat(key)
129
- for level in Level:
130
- stat_name = self._get_stat(type_data, level)
131
- if stat_name is not None:
132
- self.init_stat(stat_name)
133
-
134
- @staticmethod
135
- def default_payload() -> dict[str, Any]:
136
- return {"checks": {}, "stats": {}, "data": {"header": {}}, "graph": []}
137
-
138
- def __getitem__(self, key):
139
- return self.json[key]
140
-
141
- def _get_stat(self, type_data: str, level: Level) -> str:
142
- stat_name = Report.map_stats_suspicious.get(type_data)
143
- if stat_name is None:
144
- return None
145
- suffix = ""
146
- if level <= Level.NONE:
147
- return None
148
- if level >= Level.SUSPICIOUS:
149
- suffix = f"_{level.name}"
150
- return f"{stat_name}{suffix}".lower()
151
-
152
- def get_dot_path(self, obj, path):
153
- s_path = path.split(".")
154
- struct = obj
155
- for p in s_path[:-1]:
156
- struct.setdefault(p, {})
157
- return struct
158
-
159
- def get(self, path: str, default: Any = None) -> Any:
160
- return self.json.get(path, default)
161
-
162
- def get_check(self, path: str) -> Any:
163
- node = self.tree.get(path)
164
- if isinstance(node, ResultCheck):
165
- return node
166
- return None
167
-
168
- def get_observable_per_type(self, type: ObsType) -> list[Observable]:
169
- return [obs for obs in self.observable_registry.values() if obs.obs_type == type]
170
-
171
- def increment_stat(self, full_key: str, type_data: str, level: Level, model: Model) -> bool:
172
- stat_name = self._get_stat(type_data, level)
173
- default_stat_name = self._get_stat(type_data, Level.INFO)
174
- if stat_name is None:
175
- return False
176
- if self._seen_stats.get(full_key):
177
- # Update
178
- old_level = self._seen_stats.get(full_key)
179
- old_stat_name = self._get_stat(type_data, old_level)
180
- if stat_name and old_stat_name != stat_name and level > old_level:
181
- self._seen_stats[full_key] = level
182
- logger.debug(
183
- "{}: update stat {} -> {} - default: {} (gen by: {})",
184
- full_key,
185
- old_stat_name,
186
- stat_name,
187
- default_stat_name,
188
- model.generated_by,
189
- )
190
- if old_stat_name != default_stat_name:
191
- super().decrement_stat(old_stat_name)
192
- if stat_name != default_stat_name:
193
- super().increment_stat(stat_name)
194
- else:
195
- # New
196
- logger.debug(
197
- "{}: add stat {} - default: {} (gen by: {})", full_key, stat_name, default_stat_name, model.generated_by
198
- )
199
- if stat_name:
200
- super().increment_stat(stat_name)
201
- if stat_name != default_stat_name:
202
- super().increment_stat(default_stat_name)
203
- self._seen_stats[full_key] = level
204
- return True
205
-
206
- def get_linked_reports(self, sha256_file: str) -> Report:
207
- return self.linked_reports.get(sha256_file)
208
-
209
- def is_whitelisted(self, type: ObsType, value: str) -> bool:
210
- full_key = f"{type.name}.{value}"
211
- observable = self.observable_registry.get(full_key)
212
- return bool(observable and observable.whitelisted)
213
-
214
- def add_annotation(self, name: str, message: str) -> list[dict[str, str]]:
215
- annotation = {"name": name, "message": message}
216
- self.annotations.append(annotation)
217
- return self.annotations
218
-
219
- def has_annotations(self) -> bool:
220
- return bool(self.annotations)
221
-
222
- def visit_threat_intel(self, threat_intel: ThreatIntel) -> ThreatIntel:
223
- full_key = threat_intel.full_key
224
- # Statistic
225
- self.increment_stat(full_key, "threat_intel", threat_intel.level, threat_intel)
226
- return threat_intel
227
-
228
- def visit_result_check(self, result_check: ResultCheck) -> ResultCheck:
229
- full_key = result_check.full_key
230
- self.increment_stat(full_key, result_check.scope.name, result_check.level, result_check)
231
- rc = self.tree.integrate_result_check(result_check)
232
- logger.debug("Integrated ResultCheck [{}] -> score {}", full_key, rc.score)
233
- self.global_score = round(self.tree.total_score(), 2)
234
- self.global_level = self.tree.highest_level()
235
- return rc
236
-
237
- def visit_container(self, container: Container) -> Container:
238
- integrated = self.tree.integrate_container(container)
239
- self.global_score = round(self.tree.total_score(), 2)
240
- self.global_level = self.tree.highest_level()
241
- return integrated
242
-
243
- def visit_observable(self, observable: Observable) -> Observable:
244
- full_key = f"{observable.obs_type.name}.{observable.obs_value}"
245
- self.increment_stat(full_key, observable.obs_type.name, observable.level, observable)
246
- ref = self.observable_registry.upsert(observable)
247
- if self.is_whitelisted(ref.obs_type, ref.obs_value):
248
- ref.whitelisted = True
249
- return ref
250
-
251
- def visit_enrichment(self, enrichment: Enrichment) -> Enrichment:
252
- enrichment.ref_struct[enrichment.key] = enrichment.data
253
- self.enrichments.append(enrichment)
254
- return enrichment
255
-
256
- def to_json(self) -> dict[str, Any]:
257
- return report_to_json(self)
258
-
259
- def to_stdout_from_json(self, json_data: dict[str, Any]) -> None:
260
- stdout_from_json(self, json_data)
261
-
262
- def reduce_json_report(self, json_report: dict[str, Any]) -> dict[str, Any]:
263
- return reduce_report_json(json_report)
264
-
265
- def to_markdown_summary(self, json_data: dict[str, Any], exclude_checks: list[str] | None = None) -> str:
266
- return markdown_summary(json_data, exclude_checks=exclude_checks)
267
-
268
-
269
- class Action(Visitor):
270
- """Minimal visitor that records remediation ideas.
271
-
272
- Projects are encouraged to subclass this visitor and override the
273
- ``visit_*`` methods to trigger concrete actions (ticketing, EDR tasks,
274
- notifications, ...). The default implementation keeps things simple by
275
- logging observables or threat intelligence above a configurable level.
276
- """
277
-
278
- def __init__(self, *, level_threshold: Level = Level.SUSPICIOUS, graph: bool = False) -> None:
279
- super().__init__(graph=graph)
280
- self.level_threshold = level_threshold
281
- self.actions: list[dict[str, Any]] = []
282
-
283
- def record_action(
284
- self,
285
- *,
286
- name: str,
287
- description: str,
288
- impact: str | None = None,
289
- context: dict[str, Any] | None = None,
290
- ) -> dict[str, Any]:
291
- action = {
292
- "name": name,
293
- "description": description,
294
- "impact": impact,
295
- "context": context or {},
296
- }
297
- logger.info("[ACTION] {} -> {}", name, description)
298
- self.actions.append(action)
299
- return action
300
-
301
- def visit_threat_intel(self, threat_intel: ThreatIntel) -> ThreatIntel:
302
- if threat_intel.level >= self.level_threshold:
303
- desc = f"{threat_intel.obs_type.name} {threat_intel.obs_value} flagged as {threat_intel.level.name}"
304
- self.record_action(
305
- name=f"threat_intel::{threat_intel.name}",
306
- description=desc,
307
- impact=threat_intel.level.name,
308
- context={
309
- "score": threat_intel.score,
310
- "details": threat_intel.details,
311
- "extra": threat_intel.extra,
312
- },
313
- )
314
- return threat_intel
315
-
316
- def visit_observable(self, observable: Observable) -> Observable:
317
- return observable
318
-
319
- def visit_result_check(self, result_check: ResultCheck) -> ResultCheck:
320
- return result_check
321
-
322
- def visit_container(self, container: Container) -> Container:
323
- return container
324
-
325
- def visit_enrichment(self, enrichment: Enrichment) -> Enrichment:
326
- return enrichment
327
-
328
- def to_json(self) -> list[dict[str, Any]]:
329
- return copy.deepcopy(self.actions)
330
-
331
- def get_check(self, path: str) -> Any:
332
- return None
@@ -1,110 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: cyvest
3
- Version: 0.1.0
4
- Summary: Cybersecurity investigation model
5
- Author-email: PakitoSec <jeromep83@gmail.com>
6
- License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/PakitoSec/cyvest
8
- Project-URL: Repository, https://github.com/PakitoSec/cyvest
9
- Project-URL: Issues, https://github.com/PakitoSec/cyvest/issues
10
- Keywords: cybersecurity,incident-response,observability,threat-intelligence,reporting,graph
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Intended Audience :: Information Technology
14
- Classifier: Topic :: Security
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Operating System :: OS Independent
20
- Requires-Python: >=3.10
21
- Description-Content-Type: text/markdown
22
- License-File: LICENSE
23
- Requires-Dist: logurich>=0.1
24
- Requires-Dist: rich>=13
25
- Dynamic: license-file
26
-
27
- # Cyvest – Cyber Investigation Model
28
-
29
- Reusable investigation domain models, visitor helpers, and reporting utilities for incident responders. Cyvest provides
30
- a consistent data model for threat intelligence, observables, and result checks while keeping the visitor layer
31
- extensible for bespoke workflows.
32
-
33
- ## Features
34
-
35
- - Composition-friendly report builder that nests containers and checks.
36
- - Observable graph with automatic score/level propagation across relationships.
37
- - Visitor implementations for generating JSON/markdown reports or capturing follow-up actions.
38
- - Tested patterns for merging external intel feeds (VirusTotal, sandbox runs, allow-lists).
39
-
40
- ## Installation
41
-
42
- Cyvest targets Python 3.10+ and is published on PyPI:
43
-
44
- ```bash
45
- uv pip install cyvest
46
- ```
47
-
48
- ## Quick start
49
-
50
- Create a new report with nested containers and observables:
51
-
52
- ```python
53
- from cyvest import Level, ObsType, ReportBuilder, Scope
54
-
55
- builder = ReportBuilder(graph=True)
56
-
57
- with builder.container("body", scope=Scope.BODY) as body:
58
- check = body.add_check("url_scan", description="Detected suspicious URL")
59
- check.add_observable_chain(
60
- [
61
- {
62
- "obs_type": ObsType.URL,
63
- "value": "http://example.test",
64
- "intel": {"name": "sandbox", "score": 4, "level": Level.SUSPICIOUS},
65
- }
66
- ]
67
- )
68
-
69
- report = builder.build()
70
- print(report.to_json())
71
- ```
72
-
73
- Run the bundled example:
74
-
75
- ```bash
76
- uv sync
77
- uv run python examples/basic_report.py
78
- ```
79
-
80
- ## Development workflow
81
-
82
- Set up dependencies with uv:
83
-
84
- ```bash
85
- uv sync
86
- ```
87
-
88
- Execute the unit suite:
89
-
90
- ```bash
91
- uv run pytest tests
92
- ```
93
-
94
- Lint and format using Ruff:
95
-
96
- ```bash
97
- uv run ruff check
98
- uv run ruff format --check
99
- ```
100
-
101
- ## Graph & model axioms
102
-
103
- 1. Cyclic graphs on observables or containables are not supported.
104
- 2. Every root containable model must be visited. (Observables may be skipped because parent links are tracked.)
105
- 3. Child observables do not update result checks linked only to their parents.
106
- 4. A `ResultCheck` score cannot be changed by an observable that is mutated elsewhere.
107
- 5. Adding an observable to a `ResultCheck` promotes the check to at least `Level.INFO` (a `Level.NONE` check becomes INFO).
108
-
109
- See `examples/` and the tests under `tests/` for more scenarios, including how to subclass the provided visitors to
110
- integrate your own tooling.
@@ -1,13 +0,0 @@
1
- cyvest/__init__.py,sha256=DGa89MSj15F2VmdrC4g8UadNA4A3y95QI46RmGWWmkA,818
2
- cyvest/builder.py,sha256=j9B-xs5eYDX213RqYxWPzBTNOVE8vMgf1XtXDHaqf4w,5811
3
- cyvest/check_tree.py,sha256=SzAnnC0OuEOITWS-ptb1sXb9SKGwkvQgeGGApQ5ovF4,4539
4
- cyvest/models.py,sha256=Dl27Xj0MNKKAqYtS5-_M0s0Rp0EQ_dtIGt41ILNAMuE,26911
5
- cyvest/observable_registry.py,sha256=1LPTjhw9y_N9TdTX77cnvPJEwKGB-J_roGbl7YdC3K8,2623
6
- cyvest/report_render.py,sha256=VWWfIvDVn_9pEYy9gGUBcHP1EhAoWvv--WVmmpCNogc,12000
7
- cyvest/report_serialization.py,sha256=mrDHiW_0gM4JSnYzP3pxNo1HdHVSrYtwYX5EqguX5l8,9069
8
- cyvest/visitors.py,sha256=LJ1szpNrg0noZxkigvS4ZcVzQPDKM8kBbt2hd02k95E,12115
9
- cyvest-0.1.0.dist-info/licenses/LICENSE,sha256=EikdGl2Ew1IWY48h_1Ppwfh0QoIxXHQadQDovHSZZdE,1066
10
- cyvest-0.1.0.dist-info/METADATA,sha256=SuprQJa9USk9YGPrco2NGOFGAJ_6Qp7SyW9IPauCY_w,3367
11
- cyvest-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- cyvest-0.1.0.dist-info/top_level.txt,sha256=IC_VayuEXgD7GqVsyh1h4g8P5WgXJ2ZrbNvdKhnh_Hw,7
13
- cyvest-0.1.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 PakitoSec
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1 +0,0 @@
1
- cyvest