admina-framework 0.9.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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- admina_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Admina — Forensic Black Box — Compliance domain
|
|
17
|
+
Hash-chain integrity, immutable audit trail.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from io import BytesIO
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# minio is in the [proxy] extra. Make it optional so the forensic module
|
|
29
|
+
# is importable on a pure-SDK install (filesystem backend works without it).
|
|
30
|
+
try:
|
|
31
|
+
from minio.error import S3Error as _S3Error # type: ignore[import-untyped]
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
|
|
34
|
+
class _S3Error(Exception): # type: ignore[no-redef]
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("admina.forensic_blackbox")
|
|
39
|
+
|
|
40
|
+
# Key used to persist the chain state in MinIO.
|
|
41
|
+
_CHAIN_STATE_KEY = "_chain_state.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ForensicBlackBox:
|
|
45
|
+
"""
|
|
46
|
+
Immutable audit log with hash-chain integrity.
|
|
47
|
+
|
|
48
|
+
Three storage backends are supported, in this priority order:
|
|
49
|
+
|
|
50
|
+
1. ``boto3_client`` — generic S3-compatible (AWS S3, Cloudflare R2,
|
|
51
|
+
SeaweedFS, Garage, Ceph RGW, Backblaze B2, …). The recommended
|
|
52
|
+
backend for new air-gapped or on-premise deployments since the
|
|
53
|
+
MinIO Python SDK has been archived.
|
|
54
|
+
2. ``minio_client`` — legacy MinIO SDK. Kept for backward
|
|
55
|
+
compatibility; deprecated, will be removed in a future release.
|
|
56
|
+
3. ``filesystem_dir`` — local JSON files with the same hash-chain
|
|
57
|
+
semantics. Zero external dependencies. Default for OSS / single
|
|
58
|
+
host / development deployments.
|
|
59
|
+
|
|
60
|
+
If none of the three is configured the class still works as an
|
|
61
|
+
in-memory ledger (events are hashed and chained, but lost on
|
|
62
|
+
restart).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
minio_client=None,
|
|
68
|
+
bucket: str = "forensic-blackbox",
|
|
69
|
+
boto3_client=None,
|
|
70
|
+
filesystem_dir: str | None = None,
|
|
71
|
+
# S3 Object Lock (WORM) — only honoured by the boto3 backend
|
|
72
|
+
s3_object_lock: bool = False,
|
|
73
|
+
s3_lock_days: int = 365 * 7,
|
|
74
|
+
s3_auto_create_locked_bucket: bool = False,
|
|
75
|
+
# Retry policy for transient S3 errors
|
|
76
|
+
s3_max_retries: int = 5,
|
|
77
|
+
s3_base_delay_s: float = 0.2,
|
|
78
|
+
):
|
|
79
|
+
self.minio_client = minio_client
|
|
80
|
+
self.boto3_client = boto3_client
|
|
81
|
+
self.bucket = bucket
|
|
82
|
+
self.filesystem_dir = Path(filesystem_dir).resolve() if filesystem_dir else None
|
|
83
|
+
self.s3_object_lock = bool(s3_object_lock)
|
|
84
|
+
self.s3_lock_days = int(s3_lock_days)
|
|
85
|
+
self.s3_auto_create_locked_bucket = bool(s3_auto_create_locked_bucket)
|
|
86
|
+
self.s3_max_retries = max(0, int(s3_max_retries))
|
|
87
|
+
self.s3_base_delay_s = max(0.0, float(s3_base_delay_s))
|
|
88
|
+
self.chain_head: str = "GENESIS"
|
|
89
|
+
self.record_count: int = 0
|
|
90
|
+
if self.filesystem_dir is not None:
|
|
91
|
+
self.filesystem_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
self._ensure_bucket()
|
|
93
|
+
self._restore_chain_state()
|
|
94
|
+
|
|
95
|
+
# ── Retry / backoff helper for transient S3 failures ────────
|
|
96
|
+
def _s3_call(self, fn, *args, **kwargs):
|
|
97
|
+
"""Run *fn(*args, **kwargs)* with exponential backoff retries.
|
|
98
|
+
|
|
99
|
+
Used only by the boto3 backend; the legacy MinIO and filesystem
|
|
100
|
+
paths keep their original behaviour.
|
|
101
|
+
"""
|
|
102
|
+
import time as _time
|
|
103
|
+
|
|
104
|
+
attempt = 0
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
return fn(*args, **kwargs)
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
attempt += 1
|
|
110
|
+
if attempt > self.s3_max_retries:
|
|
111
|
+
raise
|
|
112
|
+
delay = self.s3_base_delay_s * (2 ** (attempt - 1))
|
|
113
|
+
logger.warning(
|
|
114
|
+
"S3 op %s failed (attempt %d/%d): %s — retrying in %.2fs",
|
|
115
|
+
getattr(fn, "__name__", "?"),
|
|
116
|
+
attempt,
|
|
117
|
+
self.s3_max_retries,
|
|
118
|
+
exc,
|
|
119
|
+
delay,
|
|
120
|
+
)
|
|
121
|
+
_time.sleep(delay)
|
|
122
|
+
|
|
123
|
+
def _ensure_bucket(self):
|
|
124
|
+
"""Create bucket if it doesn't exist (S3 backends only).
|
|
125
|
+
|
|
126
|
+
For the boto3 backend, optionally create with ObjectLockEnabled
|
|
127
|
+
when s3_auto_create_locked_bucket is True — this MUST happen at
|
|
128
|
+
bucket creation, it cannot be enabled retroactively.
|
|
129
|
+
"""
|
|
130
|
+
if self.boto3_client is not None:
|
|
131
|
+
try:
|
|
132
|
+
self._s3_call(self.boto3_client.head_bucket, Bucket=self.bucket)
|
|
133
|
+
except Exception: # noqa: BLE001 — bucket missing
|
|
134
|
+
kwargs = {"Bucket": self.bucket}
|
|
135
|
+
if self.s3_object_lock and self.s3_auto_create_locked_bucket:
|
|
136
|
+
kwargs["ObjectLockEnabledForBucket"] = True
|
|
137
|
+
try:
|
|
138
|
+
self._s3_call(self.boto3_client.create_bucket, **kwargs)
|
|
139
|
+
logger.info(
|
|
140
|
+
"Created forensic bucket (S3): %s%s",
|
|
141
|
+
self.bucket,
|
|
142
|
+
" (Object Lock enabled)"
|
|
143
|
+
if kwargs.get("ObjectLockEnabledForBucket")
|
|
144
|
+
else "",
|
|
145
|
+
)
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
logger.exception("Failed to create S3 forensic bucket %s", self.bucket)
|
|
148
|
+
return
|
|
149
|
+
if self.minio_client is not None:
|
|
150
|
+
try:
|
|
151
|
+
if not self.minio_client.bucket_exists(self.bucket):
|
|
152
|
+
self.minio_client.make_bucket(self.bucket)
|
|
153
|
+
logger.info("Created forensic bucket (MinIO): %s", self.bucket)
|
|
154
|
+
except _S3Error:
|
|
155
|
+
logger.exception("Failed to create forensic bucket %s", self.bucket)
|
|
156
|
+
return
|
|
157
|
+
if self.filesystem_dir is not None:
|
|
158
|
+
return # mkdir already done in __init__
|
|
159
|
+
logger.warning("No forensic backend configured — events kept in memory only")
|
|
160
|
+
|
|
161
|
+
def _restore_chain_state(self):
|
|
162
|
+
"""Restore chain_head and record_count from the configured backend."""
|
|
163
|
+
if self.boto3_client is not None:
|
|
164
|
+
try:
|
|
165
|
+
obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_KEY)
|
|
166
|
+
state = json.loads(obj["Body"].read().decode("utf-8"))
|
|
167
|
+
self.chain_head = state.get("chain_head", "GENESIS")
|
|
168
|
+
self.record_count = state.get("record_count", 0)
|
|
169
|
+
logger.info(
|
|
170
|
+
"Restored forensic chain state (S3): seq=%d, head=%s...",
|
|
171
|
+
self.record_count,
|
|
172
|
+
self.chain_head[:16],
|
|
173
|
+
)
|
|
174
|
+
except Exception: # noqa: BLE001 — NoSuchKey or similar
|
|
175
|
+
logger.info("No existing forensic chain state in S3, starting fresh")
|
|
176
|
+
return
|
|
177
|
+
if self.minio_client is not None:
|
|
178
|
+
try:
|
|
179
|
+
response = self.minio_client.get_object(self.bucket, _CHAIN_STATE_KEY)
|
|
180
|
+
state = json.loads(response.read().decode("utf-8"))
|
|
181
|
+
self.chain_head = state.get("chain_head", "GENESIS")
|
|
182
|
+
self.record_count = state.get("record_count", 0)
|
|
183
|
+
logger.info(
|
|
184
|
+
"Restored forensic chain state (MinIO): seq=%d, head=%s...",
|
|
185
|
+
self.record_count,
|
|
186
|
+
self.chain_head[:16],
|
|
187
|
+
)
|
|
188
|
+
except (_S3Error, json.JSONDecodeError):
|
|
189
|
+
logger.info("No existing forensic chain state found, starting fresh")
|
|
190
|
+
return
|
|
191
|
+
if self.filesystem_dir is not None:
|
|
192
|
+
state_path = self.filesystem_dir / _CHAIN_STATE_KEY
|
|
193
|
+
if state_path.exists():
|
|
194
|
+
try:
|
|
195
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
196
|
+
self.chain_head = state.get("chain_head", "GENESIS")
|
|
197
|
+
self.record_count = state.get("record_count", 0)
|
|
198
|
+
logger.info(
|
|
199
|
+
"Restored forensic chain state (filesystem): seq=%d, head=%s...",
|
|
200
|
+
self.record_count,
|
|
201
|
+
self.chain_head[:16],
|
|
202
|
+
)
|
|
203
|
+
except (OSError, json.JSONDecodeError):
|
|
204
|
+
logger.warning("Corrupt chain state at %s — starting fresh", state_path)
|
|
205
|
+
|
|
206
|
+
def _persist_chain_state(self):
|
|
207
|
+
"""Persist chain_head and record_count to the configured backend."""
|
|
208
|
+
payload = json.dumps(
|
|
209
|
+
{
|
|
210
|
+
"chain_head": self.chain_head,
|
|
211
|
+
"record_count": self.record_count,
|
|
212
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
213
|
+
}
|
|
214
|
+
).encode("utf-8")
|
|
215
|
+
if self.boto3_client is not None:
|
|
216
|
+
try:
|
|
217
|
+
# Chain-state is mutable by design (overwritten every
|
|
218
|
+
# write) so we deliberately do NOT lock it. Locking the
|
|
219
|
+
# individual records (in _store_to_s3) is what makes the
|
|
220
|
+
# chain tamper-evident.
|
|
221
|
+
self._s3_call(
|
|
222
|
+
self.boto3_client.put_object,
|
|
223
|
+
Bucket=self.bucket,
|
|
224
|
+
Key=_CHAIN_STATE_KEY,
|
|
225
|
+
Body=payload,
|
|
226
|
+
ContentType="application/json",
|
|
227
|
+
)
|
|
228
|
+
except Exception as e: # noqa: BLE001
|
|
229
|
+
logger.warning("Failed to persist chain state (S3): %s", e)
|
|
230
|
+
return
|
|
231
|
+
if self.minio_client is not None:
|
|
232
|
+
try:
|
|
233
|
+
self.minio_client.put_object(
|
|
234
|
+
self.bucket,
|
|
235
|
+
_CHAIN_STATE_KEY,
|
|
236
|
+
BytesIO(payload),
|
|
237
|
+
length=len(payload),
|
|
238
|
+
content_type="application/json",
|
|
239
|
+
)
|
|
240
|
+
except _S3Error as e:
|
|
241
|
+
logger.warning("Failed to persist chain state (MinIO): %s", e)
|
|
242
|
+
return
|
|
243
|
+
if self.filesystem_dir is not None:
|
|
244
|
+
try:
|
|
245
|
+
(self.filesystem_dir / _CHAIN_STATE_KEY).write_bytes(payload)
|
|
246
|
+
except OSError as e:
|
|
247
|
+
logger.warning("Failed to persist chain state (filesystem): %s", e)
|
|
248
|
+
|
|
249
|
+
def _compute_hash(self, data: str) -> str:
|
|
250
|
+
"""SHA-256 hash for chain integrity."""
|
|
251
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
252
|
+
|
|
253
|
+
def record(self, event: dict) -> dict:
|
|
254
|
+
"""
|
|
255
|
+
Record an event to the forensic black box.
|
|
256
|
+
Adds hash-chain integrity and eIDAS-style timestamp.
|
|
257
|
+
Returns the forensic record with integrity metadata.
|
|
258
|
+
"""
|
|
259
|
+
self.record_count += 1
|
|
260
|
+
|
|
261
|
+
forensic_record = {
|
|
262
|
+
"sequence_number": self.record_count,
|
|
263
|
+
"timestamp_utc": datetime.now(UTC).isoformat(),
|
|
264
|
+
"timestamp_unix_ms": int(time.time() * 1000),
|
|
265
|
+
"previous_hash": self.chain_head,
|
|
266
|
+
"event": event,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
record_json = json.dumps(forensic_record, sort_keys=True, default=str)
|
|
270
|
+
record_hash = self._compute_hash(record_json)
|
|
271
|
+
forensic_record["record_hash"] = record_hash
|
|
272
|
+
|
|
273
|
+
self.chain_head = record_hash
|
|
274
|
+
|
|
275
|
+
# Store the record and persist the updated chain state
|
|
276
|
+
self._store_to_s3(forensic_record)
|
|
277
|
+
self._persist_chain_state()
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"sequence_number": self.record_count,
|
|
281
|
+
"record_hash": record_hash,
|
|
282
|
+
"previous_hash": forensic_record["previous_hash"],
|
|
283
|
+
"stored": (
|
|
284
|
+
self.minio_client is not None
|
|
285
|
+
or self.boto3_client is not None
|
|
286
|
+
or self.filesystem_dir is not None
|
|
287
|
+
),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def _store_to_s3(self, record: dict):
|
|
291
|
+
"""Persist a forensic record using the configured backend."""
|
|
292
|
+
ts = datetime.now(UTC)
|
|
293
|
+
key = (
|
|
294
|
+
f"{ts.year}/{ts.month:02d}/{ts.day:02d}/"
|
|
295
|
+
f"{ts.hour:02d}/{record['sequence_number']:08d}.json"
|
|
296
|
+
)
|
|
297
|
+
data = json.dumps(record, sort_keys=True, default=str).encode("utf-8")
|
|
298
|
+
if self.boto3_client is not None:
|
|
299
|
+
put_kwargs: dict = {
|
|
300
|
+
"Bucket": self.bucket,
|
|
301
|
+
"Key": key,
|
|
302
|
+
"Body": data,
|
|
303
|
+
"ContentType": "application/json",
|
|
304
|
+
}
|
|
305
|
+
if self.s3_object_lock:
|
|
306
|
+
from datetime import timedelta
|
|
307
|
+
|
|
308
|
+
retain_until = datetime.now(UTC) + timedelta(days=self.s3_lock_days)
|
|
309
|
+
put_kwargs["ObjectLockMode"] = "COMPLIANCE"
|
|
310
|
+
put_kwargs["ObjectLockRetainUntilDate"] = retain_until
|
|
311
|
+
try:
|
|
312
|
+
self._s3_call(self.boto3_client.put_object, **put_kwargs)
|
|
313
|
+
logger.debug(
|
|
314
|
+
"Stored forensic record (S3%s): %s",
|
|
315
|
+
" + lock" if self.s3_object_lock else "",
|
|
316
|
+
key,
|
|
317
|
+
)
|
|
318
|
+
except Exception: # noqa: BLE001
|
|
319
|
+
logger.exception("Failed to store forensic record %s", key)
|
|
320
|
+
return
|
|
321
|
+
if self.minio_client is not None:
|
|
322
|
+
try:
|
|
323
|
+
self.minio_client.put_object(
|
|
324
|
+
self.bucket,
|
|
325
|
+
key,
|
|
326
|
+
BytesIO(data),
|
|
327
|
+
length=len(data),
|
|
328
|
+
content_type="application/json",
|
|
329
|
+
)
|
|
330
|
+
logger.debug("Stored forensic record (MinIO): %s", key)
|
|
331
|
+
except _S3Error:
|
|
332
|
+
logger.exception("Failed to store forensic record %s", key)
|
|
333
|
+
return
|
|
334
|
+
if self.filesystem_dir is not None:
|
|
335
|
+
path = self.filesystem_dir / key
|
|
336
|
+
try:
|
|
337
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
path.write_bytes(data)
|
|
339
|
+
logger.debug("Stored forensic record (filesystem): %s", path)
|
|
340
|
+
except OSError:
|
|
341
|
+
logger.exception("Failed to write forensic record %s", path)
|
|
342
|
+
return
|
|
343
|
+
# No backend → in-memory only, nothing to do
|
|
344
|
+
|
|
345
|
+
def verify_chain(self, records: list[dict]) -> dict:
|
|
346
|
+
"""
|
|
347
|
+
Verify the integrity of a chain of forensic records.
|
|
348
|
+
Returns verification result.
|
|
349
|
+
"""
|
|
350
|
+
if not records:
|
|
351
|
+
return {"valid": True, "checked": 0}
|
|
352
|
+
|
|
353
|
+
for i, record in enumerate(records):
|
|
354
|
+
event_copy = {k: v for k, v in record.items() if k != "record_hash"}
|
|
355
|
+
recomputed = self._compute_hash(json.dumps(event_copy, sort_keys=True, default=str))
|
|
356
|
+
if recomputed != record.get("record_hash"):
|
|
357
|
+
return {
|
|
358
|
+
"valid": False,
|
|
359
|
+
"error": f"Hash mismatch at sequence {record.get('sequence_number')}",
|
|
360
|
+
"checked": i,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if i > 0:
|
|
364
|
+
expected_prev = records[i - 1].get("record_hash")
|
|
365
|
+
actual_prev = record.get("previous_hash")
|
|
366
|
+
if expected_prev != actual_prev:
|
|
367
|
+
return {
|
|
368
|
+
"valid": False,
|
|
369
|
+
"error": f"Chain broken at sequence {record.get('sequence_number')}",
|
|
370
|
+
"checked": i,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {"valid": True, "checked": len(records)}
|
|
374
|
+
|
|
375
|
+
def get_stats(self) -> dict:
|
|
376
|
+
return {
|
|
377
|
+
"record_count": self.record_count,
|
|
378
|
+
"chain_head": self.chain_head[:16] + "...",
|
|
379
|
+
"storage_available": self.minio_client is not None,
|
|
380
|
+
}
|