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/stats.py ADDED
@@ -0,0 +1,291 @@
1
+ """
2
+ Statistics and aggregation engine for Cyvest investigations.
3
+
4
+ Provides live counters and aggregations for observables, checks, threat intel,
5
+ and other investigation metrics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+
12
+ from cyvest.levels import Level
13
+ from cyvest.model import Check, Observable, Tag, ThreatIntel
14
+ from cyvest.model_schema import StatisticsSchema
15
+
16
+
17
+ class InvestigationStats:
18
+ """
19
+ Tracks and aggregates statistics for an investigation.
20
+
21
+ Provides real-time metrics about observables, checks, threat intel,
22
+ and other investigation components.
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ """Initialize statistics tracking."""
27
+ self._observables: dict[str, Observable] = {}
28
+ self._checks: dict[str, Check] = {}
29
+ self._threat_intels: dict[str, ThreatIntel] = {}
30
+ self._tags: dict[str, Tag] = {}
31
+
32
+ def register_observable(self, observable: Observable) -> None:
33
+ """
34
+ Register an observable for statistics tracking.
35
+
36
+ Args:
37
+ observable: Observable to track
38
+ """
39
+ self._observables[observable.key] = observable
40
+
41
+ def register_check(self, check: Check) -> None:
42
+ """
43
+ Register a check for statistics tracking.
44
+
45
+ Args:
46
+ check: Check to track
47
+ """
48
+ self._checks[check.key] = check
49
+
50
+ def register_threat_intel(self, ti: ThreatIntel) -> None:
51
+ """
52
+ Register threat intel for statistics tracking.
53
+
54
+ Args:
55
+ ti: Threat intel to track
56
+ """
57
+ self._threat_intels[ti.key] = ti
58
+
59
+ def register_tag(self, tag: Tag) -> None:
60
+ """
61
+ Register a tag for statistics tracking.
62
+
63
+ Args:
64
+ tag: Tag to track
65
+ """
66
+ self._tags[tag.key] = tag
67
+
68
+ def get_observable_count_by_type(self) -> dict[str, int]:
69
+ """
70
+ Get count of observables by type.
71
+
72
+ Returns:
73
+ Dictionary mapping observable type to count
74
+ """
75
+ counts: dict[str, int] = defaultdict(int)
76
+ for obs in self._observables.values():
77
+ counts[obs.obs_type] += 1
78
+ return dict(counts)
79
+
80
+ def get_observable_count_by_level(self, obs_type: str | None = None) -> dict[Level, int]:
81
+ """
82
+ Get count of observables by level, optionally filtered by type.
83
+
84
+ Args:
85
+ obs_type: Optional observable type to filter by
86
+
87
+ Returns:
88
+ Dictionary mapping level to count
89
+ """
90
+ counts: dict[Level, int] = defaultdict(int)
91
+ for obs in self._observables.values():
92
+ if obs_type is None or obs.obs_type == obs_type:
93
+ counts[obs.level] += 1
94
+ return dict(counts)
95
+
96
+ def get_observable_count_by_type_and_level(self) -> dict[str, dict[Level, int]]:
97
+ """
98
+ Get count of observables by type and level.
99
+
100
+ Returns:
101
+ Nested dictionary: {type: {level: count}}
102
+ """
103
+ counts: dict[str, dict[Level, int]] = defaultdict(lambda: defaultdict(int))
104
+ for obs in self._observables.values():
105
+ counts[obs.obs_type][obs.level] += 1
106
+ # Convert defaultdicts to regular dicts
107
+ return {k: dict(v) for k, v in counts.items()}
108
+
109
+ def get_total_observable_count(self) -> int:
110
+ """
111
+ Get total number of observables.
112
+
113
+ Returns:
114
+ Total observable count
115
+ """
116
+ return len(self._observables)
117
+
118
+ def get_internal_observable_count(self) -> int:
119
+ """
120
+ Get count of internal observables.
121
+
122
+ Returns:
123
+ Count of internal observables
124
+ """
125
+ return sum(1 for obs in self._observables.values() if obs.internal)
126
+
127
+ def get_external_observable_count(self) -> int:
128
+ """
129
+ Get count of external observables.
130
+
131
+ Returns:
132
+ Count of external observables
133
+ """
134
+ return sum(1 for obs in self._observables.values() if not obs.internal)
135
+
136
+ def get_whitelisted_observable_count(self) -> int:
137
+ """
138
+ Get count of whitelisted observables.
139
+
140
+ Returns:
141
+ Count of whitelisted observables
142
+ """
143
+ return sum(1 for obs in self._observables.values() if obs.whitelisted)
144
+
145
+ def get_check_count_by_level(self) -> dict[Level, int]:
146
+ """
147
+ Get count of checks by level.
148
+
149
+ Returns:
150
+ Dictionary mapping level to count
151
+ """
152
+ counts: dict[Level, int] = defaultdict(int)
153
+ for check in self._checks.values():
154
+ counts[check.level] += 1
155
+ return dict(counts)
156
+
157
+ def get_check_keys_by_level(self) -> dict[Level, list[str]]:
158
+ """
159
+ Get check keys grouped by level.
160
+
161
+ Returns:
162
+ Dictionary mapping level to list of check keys
163
+ """
164
+ keys: dict[Level, list[str]] = defaultdict(list)
165
+ for check in self._checks.values():
166
+ keys[check.level].append(check.key)
167
+ return dict(keys)
168
+
169
+ def get_applied_check_count(self) -> int:
170
+ """
171
+ Get count of checks that were applied (level != NONE).
172
+
173
+ Returns:
174
+ Count of applied checks
175
+ """
176
+ return sum(1 for check in self._checks.values() if check.level != Level.NONE)
177
+
178
+ def get_total_check_count(self) -> int:
179
+ """
180
+ Get total number of checks.
181
+
182
+ Returns:
183
+ Total check count
184
+ """
185
+ return len(self._checks)
186
+
187
+ def get_threat_intel_count(self) -> int:
188
+ """
189
+ Get total number of threat intel sources queried.
190
+
191
+ Returns:
192
+ Total threat intel count
193
+ """
194
+ return len(self._threat_intels)
195
+
196
+ def get_threat_intel_count_by_source(self) -> dict[str, int]:
197
+ """
198
+ Get count of threat intel by source.
199
+
200
+ Returns:
201
+ Dictionary mapping source name to count
202
+ """
203
+ counts: dict[str, int] = defaultdict(int)
204
+ for ti in self._threat_intels.values():
205
+ counts[ti.source] += 1
206
+ return dict(counts)
207
+
208
+ def get_threat_intel_count_by_level(self) -> dict[Level, int]:
209
+ """
210
+ Get count of threat intel by level.
211
+
212
+ Returns:
213
+ Dictionary mapping level to count
214
+ """
215
+ counts: dict[Level, int] = defaultdict(int)
216
+ for ti in self._threat_intels.values():
217
+ counts[ti.level] += 1
218
+ return dict(counts)
219
+
220
+ def get_tag_count(self) -> int:
221
+ """
222
+ Get total number of tags.
223
+
224
+ Returns:
225
+ Total tag count
226
+ """
227
+ return len(self._tags)
228
+
229
+ def get_checks_by_level(self, level: Level) -> list[Check]:
230
+ """
231
+ Get all checks with a specific level.
232
+
233
+ Args:
234
+ level: Level to filter by
235
+
236
+ Returns:
237
+ List of checks with the specified level
238
+ """
239
+ return [check for check in self._checks.values() if check.level == level]
240
+
241
+ def get_observables_by_level(self, level: Level) -> list[Observable]:
242
+ """
243
+ Get all observables with a specific level.
244
+
245
+ Args:
246
+ level: Level to filter by
247
+
248
+ Returns:
249
+ List of observables with the specified level
250
+ """
251
+ return [obs for obs in self._observables.values() if obs.level == level]
252
+
253
+ def get_observables_by_type(self, obs_type: str) -> list[Observable]:
254
+ """
255
+ Get all observables of a specific type.
256
+
257
+ Args:
258
+ obs_type: Type to filter by
259
+
260
+ Returns:
261
+ List of observables with the specified type
262
+ """
263
+ return [obs for obs in self._observables.values() if obs.obs_type == obs_type]
264
+
265
+ def get_summary(self) -> StatisticsSchema:
266
+ """
267
+ Get a comprehensive summary of all statistics.
268
+
269
+ Returns:
270
+ StatisticsSchema instance with all statistics
271
+ """
272
+
273
+ return StatisticsSchema(
274
+ total_observables=self.get_total_observable_count(),
275
+ internal_observables=self.get_internal_observable_count(),
276
+ external_observables=self.get_external_observable_count(),
277
+ whitelisted_observables=self.get_whitelisted_observable_count(),
278
+ observables_by_type=self.get_observable_count_by_type(),
279
+ observables_by_level={str(k): v for k, v in self.get_observable_count_by_level().items()},
280
+ observables_by_type_and_level={
281
+ obs_type: {str(lvl): count for lvl, count in levels.items()}
282
+ for obs_type, levels in self.get_observable_count_by_type_and_level().items()
283
+ },
284
+ total_checks=self.get_total_check_count(),
285
+ applied_checks=self.get_applied_check_count(),
286
+ checks_by_level={str(k): v for k, v in self.get_check_keys_by_level().items()},
287
+ total_threat_intel=self.get_threat_intel_count(),
288
+ threat_intel_by_source=self.get_threat_intel_count_by_source(),
289
+ threat_intel_by_level={str(k): v for k, v in self.get_threat_intel_count_by_level().items()},
290
+ total_tags=self.get_tag_count(),
291
+ )
cyvest/ulid.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ ULID generator for stable investigation identities.
3
+
4
+ Cyvest uses ULIDs to tag investigations and to stamp provenance on Check↔Observable
5
+ links. This implementation is dependency-free and follows the 26-char Crockford
6
+ Base32 ULID encoding.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ import time
13
+
14
+ _CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
15
+
16
+
17
+ def generate_ulid(*, timestamp_ms: int | None = None) -> str:
18
+ """
19
+ Generate a ULID string.
20
+
21
+ Args:
22
+ timestamp_ms: Optional millisecond timestamp (48-bit). Defaults to current time.
23
+ """
24
+ if timestamp_ms is None:
25
+ timestamp_ms = int(time.time() * 1000)
26
+ if timestamp_ms < 0 or timestamp_ms >= (1 << 48):
27
+ raise ValueError("timestamp_ms must fit in 48 bits")
28
+
29
+ randomness = secrets.token_bytes(10) # 80 bits
30
+ value = (timestamp_ms << 80) | int.from_bytes(randomness, "big")
31
+
32
+ chars: list[str] = []
33
+ for _ in range(26):
34
+ chars.append(_CROCKFORD_BASE32_ALPHABET[value & 0x1F])
35
+ value >>= 5
36
+ return "".join(reversed(chars))