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 +21 -0
- cyvest-0.1.0/PKG-INFO +110 -0
- cyvest-0.1.0/README.md +84 -0
- cyvest-0.1.0/pyproject.toml +57 -0
- cyvest-0.1.0/setup.cfg +4 -0
- cyvest-0.1.0/src/cyvest/__init__.py +47 -0
- cyvest-0.1.0/src/cyvest/builder.py +182 -0
- cyvest-0.1.0/src/cyvest/check_tree.py +117 -0
- cyvest-0.1.0/src/cyvest/models.py +785 -0
- cyvest-0.1.0/src/cyvest/observable_registry.py +69 -0
- cyvest-0.1.0/src/cyvest/report_render.py +306 -0
- cyvest-0.1.0/src/cyvest/report_serialization.py +237 -0
- cyvest-0.1.0/src/cyvest/visitors.py +332 -0
- cyvest-0.1.0/src/cyvest.egg-info/PKG-INFO +110 -0
- cyvest-0.1.0/src/cyvest.egg-info/SOURCES.txt +20 -0
- cyvest-0.1.0/src/cyvest.egg-info/dependency_links.txt +1 -0
- cyvest-0.1.0/src/cyvest.egg-info/requires.txt +2 -0
- cyvest-0.1.0/src/cyvest.egg-info/top_level.txt +1 -0
- cyvest-0.1.0/tests/test_action.py +40 -0
- cyvest-0.1.0/tests/test_builder.py +99 -0
- cyvest-0.1.0/tests/test_models.py +138 -0
- cyvest-0.1.0/tests/test_report.py +51 -0
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,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())
|