cyvest 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
cyvest-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,110 @@
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.
cyvest-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Cyvest – Cyber Investigation Model
2
+
3
+ Reusable investigation domain models, visitor helpers, and reporting utilities for incident responders. Cyvest provides
4
+ a consistent data model for threat intelligence, observables, and result checks while keeping the visitor layer
5
+ extensible for bespoke workflows.
6
+
7
+ ## Features
8
+
9
+ - Composition-friendly report builder that nests containers and checks.
10
+ - Observable graph with automatic score/level propagation across relationships.
11
+ - Visitor implementations for generating JSON/markdown reports or capturing follow-up actions.
12
+ - Tested patterns for merging external intel feeds (VirusTotal, sandbox runs, allow-lists).
13
+
14
+ ## Installation
15
+
16
+ Cyvest targets Python 3.10+ and is published on PyPI:
17
+
18
+ ```bash
19
+ uv pip install cyvest
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ Create a new report with nested containers and observables:
25
+
26
+ ```python
27
+ from cyvest import Level, ObsType, ReportBuilder, Scope
28
+
29
+ builder = ReportBuilder(graph=True)
30
+
31
+ with builder.container("body", scope=Scope.BODY) as body:
32
+ check = body.add_check("url_scan", description="Detected suspicious URL")
33
+ check.add_observable_chain(
34
+ [
35
+ {
36
+ "obs_type": ObsType.URL,
37
+ "value": "http://example.test",
38
+ "intel": {"name": "sandbox", "score": 4, "level": Level.SUSPICIOUS},
39
+ }
40
+ ]
41
+ )
42
+
43
+ report = builder.build()
44
+ print(report.to_json())
45
+ ```
46
+
47
+ Run the bundled example:
48
+
49
+ ```bash
50
+ uv sync
51
+ uv run python examples/basic_report.py
52
+ ```
53
+
54
+ ## Development workflow
55
+
56
+ Set up dependencies with uv:
57
+
58
+ ```bash
59
+ uv sync
60
+ ```
61
+
62
+ Execute the unit suite:
63
+
64
+ ```bash
65
+ uv run pytest tests
66
+ ```
67
+
68
+ Lint and format using Ruff:
69
+
70
+ ```bash
71
+ uv run ruff check
72
+ uv run ruff format --check
73
+ ```
74
+
75
+ ## Graph & model axioms
76
+
77
+ 1. Cyclic graphs on observables or containables are not supported.
78
+ 2. Every root containable model must be visited. (Observables may be skipped because parent links are tracked.)
79
+ 3. Child observables do not update result checks linked only to their parents.
80
+ 4. A `ResultCheck` score cannot be changed by an observable that is mutated elsewhere.
81
+ 5. Adding an observable to a `ResultCheck` promotes the check to at least `Level.INFO` (a `Level.NONE` check becomes INFO).
82
+
83
+ See `examples/` and the tests under `tests/` for more scenarios, including how to subclass the provided visitors to
84
+ integrate your own tooling.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "cyvest"
3
+ version = "0.1.0"
4
+ description = "Cybersecurity investigation model"
5
+ readme = {file = "README.md", content-type = "text/markdown"}
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "PakitoSec", email = "jeromep83@gmail.com" }
11
+ ]
12
+ dependencies = ["logurich>=0.1", "rich>=13"]
13
+ keywords = ["cybersecurity", "incident-response", "observability", "threat-intelligence", "reporting", "graph"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Information Technology",
18
+ "Topic :: Security",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/PakitoSec/cyvest"
28
+ Repository = "https://github.com/PakitoSec/cyvest"
29
+ Issues = "https://github.com/PakitoSec/cyvest/issues"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8.4.2",
34
+ "ruff>=0.6",
35
+ ]
36
+
37
+ [tool.ruff]
38
+ target-version = "py310"
39
+ line-length = 120
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "B", "UP"]
43
+ ignore = []
44
+
45
+ [tool.ruff.lint.isort]
46
+ known-first-party = ["cyvest"]
47
+
48
+ [tool.setuptools]
49
+ package-dir = {"" = "src"}
50
+ include-package-data = true
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ [build-system]
56
+ requires = ["setuptools>=65", "wheel"]
57
+ build-backend = "setuptools.build_meta"
cyvest-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ """Investigation models."""
2
+
3
+ __all__: list[str]
4
+ __version__ = "0.1.0"
5
+
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,
23
+ )
24
+ from .visitors import Action, Report, Visitor
25
+
26
+ __all__ = [
27
+ "Action",
28
+ "Container",
29
+ "ContainableSLM",
30
+ "Enrichment",
31
+ "Level",
32
+ "MAP_LEVEL_DATA",
33
+ "Model",
34
+ "Observable",
35
+ "ObsType",
36
+ "Report",
37
+ "ResultCheck",
38
+ "Scope",
39
+ "ScoredLevelModel",
40
+ "ThreatIntel",
41
+ "Visitor",
42
+ "get_color_level",
43
+ "get_color_score",
44
+ "get_level_from_score",
45
+ "ReportBuilder",
46
+ "__version__",
47
+ ]
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections.abc import Iterable, Sequence
5
+ from contextlib import AbstractContextManager
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .models import ContainableSLM, Container, Level, ResultCheck, Scope
10
+ from .visitors import Report
11
+
12
+
13
+ @dataclass
14
+ class _ContainerNode:
15
+ container: Container
16
+
17
+
18
+ class _ContainerContext(AbstractContextManager["BuilderContainerProxy"]):
19
+ def __init__(self, builder: ReportBuilder, node: _ContainerNode) -> None:
20
+ self._builder = builder
21
+ self._node = node
22
+
23
+ def __enter__(self) -> BuilderContainerProxy:
24
+ self._builder._enter_container(self._node)
25
+ return BuilderContainerProxy(self._builder, self._node)
26
+
27
+ def __exit__(self, exc_type, exc, exc_tb) -> None:
28
+ self._builder._exit_container(self._node)
29
+
30
+
31
+ class BuilderContainerProxy:
32
+ def __init__(self, builder: ReportBuilder, node: _ContainerNode) -> None:
33
+ self._builder = builder
34
+ self._node = node
35
+
36
+ def add_check(
37
+ self,
38
+ path: str,
39
+ *,
40
+ identifier: str | None = None,
41
+ description: str | None = None,
42
+ level: Level = Level.INFO,
43
+ score: float = 0.0,
44
+ details: dict | None = None,
45
+ observable_chain: Sequence[dict[str, Any]] | None = None,
46
+ ) -> ResultCheck:
47
+ return self._builder.add_check(
48
+ path,
49
+ scope=self._node.container.scope,
50
+ identifier=identifier,
51
+ description=description,
52
+ level=level,
53
+ score=score,
54
+ details=details,
55
+ observable_chain=observable_chain,
56
+ )
57
+
58
+ def add_existing(self, node: ContainableSLM) -> ContainableSLM:
59
+ return self._builder.add_existing(node)
60
+
61
+ def container(
62
+ self,
63
+ path: str,
64
+ *,
65
+ scope: Scope | None = None,
66
+ description: str | None = None,
67
+ identifier: str | None = None,
68
+ level: Level = Level.INFO,
69
+ details: dict | None = None,
70
+ ) -> _ContainerContext:
71
+ container_scope = scope or self._node.container.scope
72
+ return self._builder.container(
73
+ path,
74
+ scope=container_scope,
75
+ description=description,
76
+ identifier=identifier,
77
+ level=level,
78
+ details=details,
79
+ )
80
+
81
+
82
+ class ReportBuilder:
83
+ """Helper for constructing reports with nested containers and checks."""
84
+
85
+ def __init__(self, report: Report | None = None, *, graph: bool = False) -> None:
86
+ self.report: Report = report or Report(graph=graph)
87
+ self._stack: list[_ContainerNode] = []
88
+ self._roots: list[ContainableSLM] = []
89
+ self._lock = threading.RLock()
90
+
91
+ def container(
92
+ self,
93
+ path: str,
94
+ *,
95
+ scope: Scope,
96
+ description: str | None = None,
97
+ identifier: str | None = None,
98
+ level: Level = Level.INFO,
99
+ details: dict | None = None,
100
+ ) -> _ContainerContext:
101
+ container = Container(
102
+ path,
103
+ scope=scope,
104
+ identifier=identifier,
105
+ description=description,
106
+ score=0.0,
107
+ level=level,
108
+ details=details,
109
+ )
110
+ node = _ContainerNode(container)
111
+ return _ContainerContext(self, node)
112
+
113
+ def add_check(
114
+ self,
115
+ path: str,
116
+ *,
117
+ scope: Scope | None = None,
118
+ identifier: str | None = None,
119
+ description: str | None = None,
120
+ level: Level = Level.INFO,
121
+ score: float = 0.0,
122
+ details: dict | None = None,
123
+ observable_chain: Sequence[dict[str, Any]] | None = None,
124
+ ) -> ResultCheck:
125
+ with self._lock:
126
+ check_scope = scope
127
+ if check_scope is None:
128
+ if not self._stack:
129
+ raise ValueError("Scope must be provided when adding a check outside a container context")
130
+ check_scope = self._stack[-1].container.scope
131
+ result_check = ResultCheck.create(
132
+ path,
133
+ scope=check_scope,
134
+ identifier=identifier,
135
+ description=description,
136
+ level=level,
137
+ score=score,
138
+ details=details,
139
+ )
140
+ if self._stack:
141
+ self._stack[-1].container.contain(result_check)
142
+ else:
143
+ self._roots.append(result_check)
144
+ if observable_chain:
145
+ result_check.add_observable_chain(observable_chain)
146
+ return result_check
147
+
148
+ def build(self) -> Report:
149
+ with self._lock:
150
+ roots = list(self._roots)
151
+ self._roots.clear()
152
+ for root in roots:
153
+ root.accept(self.report)
154
+ return self.report
155
+
156
+ def add_existing(self, node: ContainableSLM) -> ContainableSLM:
157
+ with self._lock:
158
+ if self._stack:
159
+ self._stack[-1].container.contain(node)
160
+ else:
161
+ self._roots.append(node)
162
+ return node
163
+
164
+ def extend_existing(self, nodes: Iterable[ContainableSLM]) -> None:
165
+ for node in nodes:
166
+ self.add_existing(node)
167
+
168
+ # Internal helpers -------------------------------------------------
169
+ def _enter_container(self, node: _ContainerNode) -> None:
170
+ with self._lock:
171
+ if self._stack:
172
+ parent = self._stack[-1].container
173
+ parent.contain(node.container)
174
+ else:
175
+ self._roots.append(node.container)
176
+ self._stack.append(node)
177
+
178
+ def _exit_container(self, node: _ContainerNode) -> None:
179
+ with self._lock:
180
+ if not self._stack or self._stack[-1] is not node:
181
+ raise RuntimeError("Container context stack is inconsistent")
182
+ self._stack.pop()
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict, defaultdict
4
+ from collections.abc import Iterable
5
+
6
+ from .models import ContainableSLM, Container, Level, ResultCheck
7
+
8
+
9
+ class CheckTree:
10
+ """Canonical structure that stores containers/result checks and keeps their scores in sync."""
11
+
12
+ def __init__(self) -> None:
13
+ self._nodes: dict[str, ContainableSLM] = {}
14
+ self._roots: dict[str, OrderedDict[str, ContainableSLM]] = defaultdict(OrderedDict)
15
+
16
+ def integrate_container(self, container: Container) -> Container:
17
+ return self._integrate_container(container, seen=set())
18
+
19
+ def integrate_result_check(self, result_check: ResultCheck) -> ResultCheck:
20
+ return self._integrate_result_check(result_check, seen=set())
21
+
22
+ def _integrate_container(self, container: Container, seen: set[int]) -> Container:
23
+ if id(container) in seen:
24
+ existing = self._nodes.get(container.full_key)
25
+ if isinstance(existing, Container):
26
+ return existing
27
+ seen.add(id(container))
28
+ parent = container.parent
29
+ if parent is not None:
30
+ parent = self._integrate_container(parent, seen)
31
+ container.set_parent(parent)
32
+ key = container.full_key
33
+ existing = self._nodes.get(key)
34
+ if isinstance(existing, Container):
35
+ existing.merge_from(container)
36
+ target = existing
37
+ else:
38
+ target = container
39
+ self._nodes[key] = target
40
+ if target.parent is not None:
41
+ target.parent.attach_child(target)
42
+ else:
43
+ root_scope = target.scope.name if target.scope else "GLOBAL"
44
+ self._roots[root_scope][key] = target
45
+ # integrate children
46
+ for child in list(container.children):
47
+ if isinstance(child, Container):
48
+ child.set_parent(target)
49
+ self._integrate_container(child, seen)
50
+ else:
51
+ child.set_parent(target)
52
+ self._integrate_result_check(child, seen)
53
+ target.recompute()
54
+ self._recompute_upwards(target.parent)
55
+ return target
56
+
57
+ def _integrate_result_check(self, result_check: ResultCheck, seen: set[int]) -> ResultCheck:
58
+ if id(result_check) in seen:
59
+ existing = self._nodes.get(result_check.full_key)
60
+ if isinstance(existing, ResultCheck):
61
+ return existing
62
+ seen.add(id(result_check))
63
+ parent = result_check.parent
64
+ if parent is not None:
65
+ parent = self._integrate_container(parent, seen)
66
+ result_check.set_parent(parent)
67
+ key = result_check.full_key
68
+ existing = self._nodes.get(key)
69
+ if isinstance(existing, ResultCheck):
70
+ existing.merge_from(result_check)
71
+ target = existing
72
+ else:
73
+ target = result_check
74
+ self._nodes[key] = target
75
+ if target.parent is not None:
76
+ target.parent.attach_child(target)
77
+ else:
78
+ scope_name = target.scope.name if target.scope else "GLOBAL"
79
+ self._roots[scope_name][key] = target
80
+ self._recompute_upwards(target.parent)
81
+ return target
82
+
83
+ def _recompute_upwards(self, node: Container | None) -> None:
84
+ current = node
85
+ while current is not None:
86
+ current.recompute()
87
+ current = current.parent
88
+
89
+ def get(self, full_key: str) -> ContainableSLM | None:
90
+ return self._nodes.get(full_key)
91
+
92
+ def total_score(self) -> float:
93
+ return sum(node.score for node in self._nodes.values() if isinstance(node, ResultCheck))
94
+
95
+ def highest_level(self) -> Level:
96
+ highest = Level.INFO
97
+ for node in self._nodes.values():
98
+ if node.level > highest:
99
+ highest = node.level
100
+ return highest
101
+
102
+ def result_checks(self) -> Iterable[ResultCheck]:
103
+ for node in self._nodes.values():
104
+ if isinstance(node, ResultCheck):
105
+ yield node
106
+
107
+ def containers(self) -> Iterable[Container]:
108
+ for node in self._nodes.values():
109
+ if isinstance(node, Container):
110
+ yield node
111
+
112
+ def roots_for_scope(self, scope_name: str) -> list[ContainableSLM]:
113
+ return list(self._roots.get(scope_name, {}).values())
114
+
115
+ def iter_roots(self) -> Iterable[tuple[str, list[ContainableSLM]]]:
116
+ for scope_name, nodes in self._roots.items():
117
+ yield scope_name, list(nodes.values())