spanforge 1.0.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/sdk/audit.py
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
"""spanforge.sdk.audit — SpanForge sf-audit high-level client (Phase 4).
|
|
2
|
+
|
|
3
|
+
Implements the full sf-audit API surface for Phase 4 of the SpanForge roadmap.
|
|
4
|
+
All operations run locally in-process (zero external dependencies) when
|
|
5
|
+
``config.endpoint`` is empty or when the remote service is unreachable and
|
|
6
|
+
``local_fallback_enabled`` is ``True``.
|
|
7
|
+
|
|
8
|
+
Architecture
|
|
9
|
+
------------
|
|
10
|
+
* :meth:`append` is the **single call site** for writing audit records.
|
|
11
|
+
It validates the schema key, signs the record with HMAC-SHA256, writes it
|
|
12
|
+
to the configured backend (local JSONL / BYOS S3/Azure/GCS/R2), and
|
|
13
|
+
additionally writes a T.R.U.S.T. summary record for score-related schemas.
|
|
14
|
+
* :meth:`sign` wraps the low-level ``signing.sign()`` function so callers
|
|
15
|
+
never import from the internal module directly.
|
|
16
|
+
* :meth:`verify_chain` wraps ``signing.verify_chain()``.
|
|
17
|
+
* :meth:`export` performs date-range queries using a SQLite-backed index
|
|
18
|
+
enabling O(log n) lookups without a full file scan.
|
|
19
|
+
* :meth:`get_trust_scorecard` aggregates the T.R.U.S.T. store into
|
|
20
|
+
dimension scores with trend detection.
|
|
21
|
+
* :meth:`generate_article30_record` produces a GDPR Article 30 RoPA document.
|
|
22
|
+
* :meth:`get_status` contributes sf-audit health information to the platform
|
|
23
|
+
status endpoint.
|
|
24
|
+
|
|
25
|
+
Security requirements
|
|
26
|
+
---------------------
|
|
27
|
+
* HMAC signing keys are **never** logged or included in exception messages.
|
|
28
|
+
* ``org_secret`` is read from ``config.signing_key`` which itself is a plain
|
|
29
|
+
string (not a ``SecretStr``) — callers must ensure it is not logged.
|
|
30
|
+
* SQLite database files are stored under the system temp directory by default
|
|
31
|
+
and are cleaned up on close when ``persist_index=False``.
|
|
32
|
+
* Thread-safety: all in-memory stores and the SQLite connection use locks.
|
|
33
|
+
* BYOS credentials are injected via ``SFClientConfig`` and never echoed.
|
|
34
|
+
|
|
35
|
+
Local-mode feature parity
|
|
36
|
+
--------------------------
|
|
37
|
+
* :meth:`append` — schema validation + HMAC chain + SQLite index
|
|
38
|
+
* :meth:`sign` — raw-dict signing (AUD-003)
|
|
39
|
+
* :meth:`verify_chain` — full chain verification (AUD-004)
|
|
40
|
+
* :meth:`export` — date-range query (AUD-005)
|
|
41
|
+
* :meth:`get_trust_scorecard` — T.R.U.S.T. scorecard (AUD-031)
|
|
42
|
+
* :meth:`generate_article30_record` — GDPR RoPA (AUD-042)
|
|
43
|
+
* :meth:`get_status` — health check
|
|
44
|
+
|
|
45
|
+
BYOS providers supported (AUD-011)
|
|
46
|
+
------------------------------------
|
|
47
|
+
``[audit.byos] provider = "s3" | "azure" | "gcs" | "r2"``
|
|
48
|
+
|
|
49
|
+
The BYOS backend is selected at construction time from:
|
|
50
|
+
``SPANFORGE_AUDIT_BYOS_PROVIDER`` env var → ``config.byos_provider`` → ``None`` (local)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import contextlib
|
|
56
|
+
import hashlib
|
|
57
|
+
import hmac as _hmac
|
|
58
|
+
import json
|
|
59
|
+
import logging
|
|
60
|
+
import os
|
|
61
|
+
import sqlite3
|
|
62
|
+
import tempfile
|
|
63
|
+
import threading
|
|
64
|
+
import uuid
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from datetime import datetime, timezone
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Any
|
|
69
|
+
|
|
70
|
+
from spanforge.sdk._base import SFClientConfig, SFServiceClient
|
|
71
|
+
from spanforge.sdk._exceptions import (
|
|
72
|
+
SFAuditAppendError,
|
|
73
|
+
SFAuditError, # noqa: F401 (re-exported for callers)
|
|
74
|
+
SFAuditQueryError,
|
|
75
|
+
SFAuditSchemaError,
|
|
76
|
+
)
|
|
77
|
+
from spanforge.sdk._types import (
|
|
78
|
+
Article30Record,
|
|
79
|
+
AuditAppendResult,
|
|
80
|
+
AuditStatusInfo,
|
|
81
|
+
SignedRecord,
|
|
82
|
+
TrustDimension,
|
|
83
|
+
TrustScorecard,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = ["SFAuditClient"]
|
|
87
|
+
|
|
88
|
+
_log = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Known schema keys (AUD-002)
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
KNOWN_SCHEMA_KEYS: frozenset[str] = frozenset(
|
|
95
|
+
{
|
|
96
|
+
"halluccheck.score.v1",
|
|
97
|
+
"halluccheck.bias.v1",
|
|
98
|
+
"halluccheck.prri.v1",
|
|
99
|
+
"halluccheck.drift.v1",
|
|
100
|
+
"halluccheck.opa.v1",
|
|
101
|
+
"halluccheck.pii.v1",
|
|
102
|
+
"halluccheck.secrets.v1",
|
|
103
|
+
"halluccheck.gate.v1",
|
|
104
|
+
"halluccheck.auth.v1",
|
|
105
|
+
"halluccheck.benchmark_run.v1",
|
|
106
|
+
"halluccheck.benchmark_version.v1",
|
|
107
|
+
"spanforge.auth.v1",
|
|
108
|
+
"spanforge.consent.v1",
|
|
109
|
+
"spanforge.explanation.v1",
|
|
110
|
+
"spanforge.grounding.v1",
|
|
111
|
+
"spanforge.lineage.v1",
|
|
112
|
+
"spanforge.policy.comparison.v1",
|
|
113
|
+
"spanforge.policy.decision.v1",
|
|
114
|
+
"spanforge.policy.lifecycle.v1",
|
|
115
|
+
"spanforge.policy.replay.v1",
|
|
116
|
+
"spanforge.policy.review.v1",
|
|
117
|
+
"spanforge.policy.simulation.v1",
|
|
118
|
+
"spanforge.rbac.v1",
|
|
119
|
+
"spanforge.scope.v1",
|
|
120
|
+
# Internal T.R.U.S.T. store schema
|
|
121
|
+
"spanforge.trust.v1",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Schema keys that feed the T.R.U.S.T. store (AUD-030)
|
|
126
|
+
_TRUST_FEED_SCHEMAS: dict[str, str] = {
|
|
127
|
+
"halluccheck.score.v1": "hallucination",
|
|
128
|
+
"halluccheck.pii.v1": "pii_hygiene",
|
|
129
|
+
"halluccheck.secrets.v1": "secrets_hygiene",
|
|
130
|
+
"halluccheck.gate.v1": "gate_pass_rate",
|
|
131
|
+
"halluccheck.bias.v1": "hallucination",
|
|
132
|
+
"halluccheck.drift.v1": "hallucination",
|
|
133
|
+
"halluccheck.opa.v1": "compliance_posture",
|
|
134
|
+
"halluccheck.prri.v1": "compliance_posture",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Default retention years (AUD-010)
|
|
138
|
+
_DEFAULT_RETENTION_YEARS: int = 7
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# BYOS backend detection
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
_BYOS_PROVIDERS: frozenset[str] = frozenset({"s3", "azure", "gcs", "r2"})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _detect_byos_provider() -> str | None:
|
|
148
|
+
"""Return the BYOS provider name from env var or ``None`` for local mode."""
|
|
149
|
+
raw = os.environ.get("SPANFORGE_AUDIT_BYOS_PROVIDER", "").strip().lower()
|
|
150
|
+
return raw if raw in _BYOS_PROVIDERS else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# SQLite index schema (AUD-020)
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
_INDEX_DDL = """\
|
|
158
|
+
CREATE TABLE IF NOT EXISTS audit_index (
|
|
159
|
+
record_id TEXT NOT NULL PRIMARY KEY,
|
|
160
|
+
schema_key TEXT NOT NULL,
|
|
161
|
+
project_id TEXT NOT NULL DEFAULT '',
|
|
162
|
+
ts TEXT NOT NULL,
|
|
163
|
+
file_path TEXT NOT NULL DEFAULT '',
|
|
164
|
+
byte_offset INTEGER NOT NULL DEFAULT 0
|
|
165
|
+
);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS idx_schema_ts ON audit_index (schema_key, ts);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_project_ts ON audit_index (project_id, ts);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_schema_proj ON audit_index (schema_key, project_id, ts);
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Internal record store (in-memory + JSONL persistence)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class _AuditRecord:
|
|
179
|
+
"""Internal representation of a stored audit record."""
|
|
180
|
+
|
|
181
|
+
record_id: str
|
|
182
|
+
schema_key: str
|
|
183
|
+
project_id: str
|
|
184
|
+
timestamp: str
|
|
185
|
+
hmac: str
|
|
186
|
+
chain_position: int
|
|
187
|
+
payload: dict[str, Any]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _LocalAuditStore:
|
|
191
|
+
"""Thread-safe in-memory + SQLite audit record store."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, db_path: str) -> None:
|
|
194
|
+
self._lock = threading.Lock()
|
|
195
|
+
self._records: list[_AuditRecord] = []
|
|
196
|
+
self._trust_records: list[dict[str, Any]] = []
|
|
197
|
+
self._db_path = db_path
|
|
198
|
+
self._db: sqlite3.Connection | None = None
|
|
199
|
+
self._init_db()
|
|
200
|
+
|
|
201
|
+
def _init_db(self) -> None:
|
|
202
|
+
"""Initialise the SQLite index database."""
|
|
203
|
+
try:
|
|
204
|
+
self._db = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
205
|
+
self._db.executescript(_INDEX_DDL)
|
|
206
|
+
self._db.commit()
|
|
207
|
+
except Exception as exc: # pragma: no cover
|
|
208
|
+
_log.warning("sf-audit: SQLite index init failed: %s", exc)
|
|
209
|
+
self._db = None
|
|
210
|
+
|
|
211
|
+
def append(self, rec: _AuditRecord) -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._records.append(rec)
|
|
214
|
+
self._index_record(rec)
|
|
215
|
+
|
|
216
|
+
def _index_record(self, rec: _AuditRecord) -> None:
|
|
217
|
+
if self._db is None:
|
|
218
|
+
return
|
|
219
|
+
try:
|
|
220
|
+
self._db.execute(
|
|
221
|
+
"INSERT OR REPLACE INTO audit_index "
|
|
222
|
+
"(record_id, schema_key, project_id, ts, file_path, byte_offset) "
|
|
223
|
+
"VALUES (?, ?, ?, ?, '', 0)",
|
|
224
|
+
(rec.record_id, rec.schema_key, rec.project_id, rec.timestamp),
|
|
225
|
+
)
|
|
226
|
+
self._db.commit()
|
|
227
|
+
except Exception as exc: # pragma: no cover
|
|
228
|
+
_log.warning("sf-audit: SQLite index write failed: %s", exc)
|
|
229
|
+
|
|
230
|
+
def append_trust(self, trust_rec: dict[str, Any]) -> None:
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._trust_records.append(trust_rec)
|
|
233
|
+
|
|
234
|
+
def query(
|
|
235
|
+
self,
|
|
236
|
+
schema_key: str | None,
|
|
237
|
+
project_id: str | None,
|
|
238
|
+
from_ts: str | None,
|
|
239
|
+
to_ts: str | None,
|
|
240
|
+
) -> list[dict[str, Any]]:
|
|
241
|
+
"""Query records using the SQLite index for O(log n) date-range access."""
|
|
242
|
+
with self._lock:
|
|
243
|
+
if self._db is not None:
|
|
244
|
+
return self._query_via_db(schema_key, project_id, from_ts, to_ts)
|
|
245
|
+
return self._query_linear(schema_key, project_id, from_ts, to_ts)
|
|
246
|
+
|
|
247
|
+
def _query_via_db(
|
|
248
|
+
self,
|
|
249
|
+
schema_key: str | None,
|
|
250
|
+
project_id: str | None,
|
|
251
|
+
from_ts: str | None,
|
|
252
|
+
to_ts: str | None,
|
|
253
|
+
) -> list[dict[str, Any]]:
|
|
254
|
+
"""Use the SQLite index to find matching record IDs, then hydrate."""
|
|
255
|
+
assert self._db is not None # nosec B101 — guarded by caller
|
|
256
|
+
# Build a parameterized query — all filter values are bound params,
|
|
257
|
+
# never interpolated, so there is no SQL injection risk.
|
|
258
|
+
clauses: list[str] = []
|
|
259
|
+
params: list[Any] = []
|
|
260
|
+
if schema_key:
|
|
261
|
+
clauses.append("schema_key = ?")
|
|
262
|
+
params.append(schema_key)
|
|
263
|
+
if project_id:
|
|
264
|
+
clauses.append("project_id = ?")
|
|
265
|
+
params.append(project_id)
|
|
266
|
+
if from_ts:
|
|
267
|
+
clauses.append("ts >= ?")
|
|
268
|
+
params.append(from_ts)
|
|
269
|
+
if to_ts:
|
|
270
|
+
clauses.append("ts <= ?")
|
|
271
|
+
params.append(to_ts)
|
|
272
|
+
|
|
273
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
274
|
+
sql = f"SELECT record_id FROM audit_index {where} ORDER BY ts" # noqa: S608 # nosec B608
|
|
275
|
+
try:
|
|
276
|
+
rows = self._db.execute(sql, params).fetchall()
|
|
277
|
+
except Exception as exc: # pragma: no cover
|
|
278
|
+
_log.warning("sf-audit: SQLite query failed, falling back to linear: %s", exc)
|
|
279
|
+
return self._query_linear(schema_key, project_id, from_ts, to_ts)
|
|
280
|
+
|
|
281
|
+
record_map = {r.record_id: r for r in self._records}
|
|
282
|
+
return [_record_to_dict(record_map[row[0]]) for row in rows if row[0] in record_map]
|
|
283
|
+
|
|
284
|
+
def _query_linear(
|
|
285
|
+
self,
|
|
286
|
+
schema_key: str | None,
|
|
287
|
+
project_id: str | None,
|
|
288
|
+
from_ts: str | None,
|
|
289
|
+
to_ts: str | None,
|
|
290
|
+
) -> list[dict[str, Any]]:
|
|
291
|
+
"""Fallback linear scan when SQLite is unavailable."""
|
|
292
|
+
results = []
|
|
293
|
+
for rec in self._records:
|
|
294
|
+
if schema_key and rec.schema_key != schema_key:
|
|
295
|
+
continue
|
|
296
|
+
if project_id and rec.project_id != project_id:
|
|
297
|
+
continue
|
|
298
|
+
if from_ts and rec.timestamp < from_ts:
|
|
299
|
+
continue
|
|
300
|
+
if to_ts and rec.timestamp > to_ts:
|
|
301
|
+
continue
|
|
302
|
+
results.append(_record_to_dict(rec))
|
|
303
|
+
return results
|
|
304
|
+
|
|
305
|
+
def query_trust(
|
|
306
|
+
self, project_id: str | None, from_ts: str | None, to_ts: str | None
|
|
307
|
+
) -> list[dict[str, Any]]:
|
|
308
|
+
with self._lock:
|
|
309
|
+
results = []
|
|
310
|
+
for rec in self._trust_records:
|
|
311
|
+
if project_id and rec.get("project_id") != project_id:
|
|
312
|
+
continue
|
|
313
|
+
ts = rec.get("timestamp", "")
|
|
314
|
+
if from_ts and ts < from_ts:
|
|
315
|
+
continue
|
|
316
|
+
if to_ts and ts > to_ts:
|
|
317
|
+
continue
|
|
318
|
+
results.append(rec)
|
|
319
|
+
return results
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def record_count(self) -> int:
|
|
323
|
+
with self._lock:
|
|
324
|
+
return len(self._records)
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def last_append_at(self) -> str | None:
|
|
328
|
+
with self._lock:
|
|
329
|
+
if not self._records:
|
|
330
|
+
return None
|
|
331
|
+
return self._records[-1].timestamp
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def index_healthy(self) -> bool:
|
|
335
|
+
with self._lock:
|
|
336
|
+
return self._db is not None
|
|
337
|
+
|
|
338
|
+
def close(self) -> None:
|
|
339
|
+
with self._lock:
|
|
340
|
+
if self._db is not None:
|
|
341
|
+
with contextlib.suppress(Exception): # pragma: no cover
|
|
342
|
+
self._db.close()
|
|
343
|
+
self._db = None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _record_to_dict(rec: _AuditRecord) -> dict[str, Any]:
|
|
347
|
+
return {
|
|
348
|
+
"record_id": rec.record_id,
|
|
349
|
+
"schema_key": rec.schema_key,
|
|
350
|
+
"project_id": rec.project_id,
|
|
351
|
+
"timestamp": rec.timestamp,
|
|
352
|
+
"hmac": rec.hmac,
|
|
353
|
+
"chain_position": rec.chain_position,
|
|
354
|
+
**rec.payload,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# HMAC helpers (low-level, no Event dependency)
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
_HMAC_ALGO = "sha256"
|
|
363
|
+
_FALLBACK_SIGNING_KEY = "spanforge-audit-local-insecure-dev-key" # nosec B105
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _utc_now_iso() -> str:
|
|
367
|
+
"""Return the current UTC time as an ISO-8601 microsecond-precision string."""
|
|
368
|
+
return datetime.now(tz=timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _compute_record_hmac(record_id: str, payload_json: str, org_secret: str) -> str:
|
|
372
|
+
"""Compute ``"hmac-sha256:<hex>"`` for a raw dict audit record."""
|
|
373
|
+
msg = f"{record_id}|{payload_json}"
|
|
374
|
+
mac = _hmac.new(
|
|
375
|
+
key=org_secret.encode("utf-8"),
|
|
376
|
+
msg=msg.encode("utf-8"),
|
|
377
|
+
digestmod=hashlib.sha256,
|
|
378
|
+
)
|
|
379
|
+
return f"hmac-sha256:{mac.hexdigest()}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _compute_dict_checksum(payload: dict[str, Any]) -> str:
|
|
383
|
+
"""Return ``"sha256:<hex>"`` of the canonical JSON of *payload*."""
|
|
384
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
385
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
386
|
+
return f"sha256:{digest}"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
# T.R.U.S.T. store helpers (AUD-030)
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
_TRUST_DIMENSION_MAP = _TRUST_FEED_SCHEMAS
|
|
394
|
+
|
|
395
|
+
_TRUST_TREND_THRESHOLD: float = 2.0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _build_trust_record( # noqa: PLR0913
|
|
399
|
+
schema_key: str,
|
|
400
|
+
record_id: str,
|
|
401
|
+
project_id: str,
|
|
402
|
+
payload: dict[str, Any],
|
|
403
|
+
hmac_value: str,
|
|
404
|
+
timestamp: str,
|
|
405
|
+
) -> dict[str, Any]:
|
|
406
|
+
"""Build a T.R.U.S.T. store record from an audit append."""
|
|
407
|
+
trust_dim = _TRUST_DIMENSION_MAP.get(schema_key, "compliance_posture")
|
|
408
|
+
return {
|
|
409
|
+
"trust_dimension": trust_dim,
|
|
410
|
+
"signal_source": "halluccheck",
|
|
411
|
+
"project_id": project_id,
|
|
412
|
+
"record_type": schema_key,
|
|
413
|
+
"record_id": record_id,
|
|
414
|
+
"verdict": payload.get("verdict") or payload.get("status") or "unknown",
|
|
415
|
+
"score": payload.get("score") or payload.get("hri") or 0.0,
|
|
416
|
+
"domain": payload.get("domain") or payload.get("model") or "",
|
|
417
|
+
"hmac": hmac_value,
|
|
418
|
+
"timestamp": timestamp,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Scorecard computation helpers (AUD-031)
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
_DIMENSION_NAMES = (
|
|
427
|
+
"hallucination",
|
|
428
|
+
"pii_hygiene",
|
|
429
|
+
"secrets_hygiene",
|
|
430
|
+
"gate_pass_rate",
|
|
431
|
+
"compliance_posture",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
_SCHEMA_TO_DIM: dict[str, str] = {
|
|
435
|
+
"hallucination": "halluccheck.score.v1",
|
|
436
|
+
"pii_hygiene": "halluccheck.pii.v1",
|
|
437
|
+
"secrets_hygiene": "halluccheck.secrets.v1",
|
|
438
|
+
"gate_pass_rate": "halluccheck.gate.v1", # nosec B105
|
|
439
|
+
"compliance_posture": "halluccheck.opa.v1",
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _compute_dimension_score(records: list[dict[str, Any]]) -> tuple[float, str]:
|
|
444
|
+
"""Compute score (0-100) and trend from a list of trust records.
|
|
445
|
+
|
|
446
|
+
Returns ``(score, trend)`` where trend is ``"up"``, ``"flat"``, or ``"down"``.
|
|
447
|
+
"""
|
|
448
|
+
if not records:
|
|
449
|
+
return 50.0, "flat"
|
|
450
|
+
|
|
451
|
+
raw_scores = []
|
|
452
|
+
for r in records:
|
|
453
|
+
s = r.get("score")
|
|
454
|
+
try:
|
|
455
|
+
v = float(s) # type: ignore[arg-type]
|
|
456
|
+
except (TypeError, ValueError):
|
|
457
|
+
v = 0.5
|
|
458
|
+
# Normalise [0,1] → [0,100]; values already >1 assumed to be 0-100
|
|
459
|
+
raw_scores.append(v * 100 if v <= 1.0 else min(v, 100.0))
|
|
460
|
+
|
|
461
|
+
if not raw_scores:
|
|
462
|
+
return 50.0, "flat"
|
|
463
|
+
|
|
464
|
+
avg = sum(raw_scores) / len(raw_scores)
|
|
465
|
+
|
|
466
|
+
# Trend: compare first half vs second half
|
|
467
|
+
mid = max(1, len(raw_scores) // 2)
|
|
468
|
+
first_half = sum(raw_scores[:mid]) / mid
|
|
469
|
+
second_half = sum(raw_scores[mid:]) / max(1, len(raw_scores) - mid)
|
|
470
|
+
|
|
471
|
+
delta = second_half - first_half
|
|
472
|
+
if delta > _TRUST_TREND_THRESHOLD:
|
|
473
|
+
trend = "up"
|
|
474
|
+
elif delta < -_TRUST_TREND_THRESHOLD:
|
|
475
|
+
trend = "down"
|
|
476
|
+
else:
|
|
477
|
+
trend = "flat"
|
|
478
|
+
|
|
479
|
+
return round(avg, 2), trend
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# RFC 3161 DER encoding helpers
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _der_length(n: int) -> bytes:
|
|
488
|
+
"""Return the DER length encoding for *n* bytes."""
|
|
489
|
+
if n < 0x80:
|
|
490
|
+
return bytes([n])
|
|
491
|
+
if n < 0x100:
|
|
492
|
+
return bytes([0x81, n])
|
|
493
|
+
if n < 0x10000:
|
|
494
|
+
return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
|
|
495
|
+
raise ValueError(f"DER length too large: {n}") # pragma: no cover
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# SFAuditClient
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class SFAuditClient(SFServiceClient):
|
|
504
|
+
"""SpanForge sf-audit high-level service client (Phase 4).
|
|
505
|
+
|
|
506
|
+
Provides a single ``append(record, schema_key)`` call site for writing
|
|
507
|
+
tamper-evident audit records, wrapping the low-level
|
|
508
|
+
:class:`~spanforge.signing.AuditStream` and
|
|
509
|
+
:class:`~spanforge.export.append_only.AppendOnlyJSONLExporter`
|
|
510
|
+
primitives.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
config: Client configuration.
|
|
514
|
+
strict_schema: If ``True`` (default), reject unknown schema keys
|
|
515
|
+
with :exc:`~spanforge.sdk._exceptions.SFAuditSchemaError`.
|
|
516
|
+
Set to ``False`` to allow custom schema keys.
|
|
517
|
+
retention_years: Retention policy in years (default: 7).
|
|
518
|
+
byos_provider: BYOS provider name (``"s3"``, ``"azure"``, ``"gcs"``,
|
|
519
|
+
``"r2"``), or ``None`` for local mode.
|
|
520
|
+
db_path: Path to the SQLite index database file.
|
|
521
|
+
Defaults to a temp file when ``None``.
|
|
522
|
+
persist_index: If ``True``, the SQLite file survives restarts.
|
|
523
|
+
If ``False`` (default), the temp file is removed on
|
|
524
|
+
:meth:`close`.
|
|
525
|
+
|
|
526
|
+
Example::
|
|
527
|
+
|
|
528
|
+
from spanforge.sdk import sf_audit
|
|
529
|
+
|
|
530
|
+
result = sf_audit.append(
|
|
531
|
+
{"model": "gpt-4o", "verdict": "PASS", "score": 0.91},
|
|
532
|
+
schema_key="halluccheck.score.v1",
|
|
533
|
+
)
|
|
534
|
+
print(result.record_id)
|
|
535
|
+
print(result.chain_position)
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__( # noqa: PLR0913
|
|
539
|
+
self,
|
|
540
|
+
config: SFClientConfig,
|
|
541
|
+
*,
|
|
542
|
+
strict_schema: bool = True,
|
|
543
|
+
retention_years: int = _DEFAULT_RETENTION_YEARS,
|
|
544
|
+
byos_provider: str | None = None,
|
|
545
|
+
db_path: str | None = None,
|
|
546
|
+
persist_index: bool = False,
|
|
547
|
+
) -> None:
|
|
548
|
+
super().__init__(config, service_name="audit")
|
|
549
|
+
self._strict_schema = strict_schema
|
|
550
|
+
self._retention_years = retention_years
|
|
551
|
+
self._byos_provider = byos_provider or _detect_byos_provider()
|
|
552
|
+
self._chain_lock = threading.Lock()
|
|
553
|
+
self._chain_position: int = 0
|
|
554
|
+
self._last_hmac: str | None = None
|
|
555
|
+
|
|
556
|
+
# SQLite index
|
|
557
|
+
if db_path:
|
|
558
|
+
self._db_path = db_path
|
|
559
|
+
self._persist_index = True
|
|
560
|
+
else:
|
|
561
|
+
fd, tmp = tempfile.mkstemp(suffix=".db", prefix="sf_audit_")
|
|
562
|
+
os.close(fd)
|
|
563
|
+
self._db_path = tmp
|
|
564
|
+
self._persist_index = persist_index
|
|
565
|
+
|
|
566
|
+
self._store = _LocalAuditStore(self._db_path)
|
|
567
|
+
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
# AUD-001: append
|
|
570
|
+
# ------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
def append(
|
|
573
|
+
self,
|
|
574
|
+
record: dict[str, Any],
|
|
575
|
+
schema_key: str,
|
|
576
|
+
*,
|
|
577
|
+
project_id: str = "",
|
|
578
|
+
strict_schema: bool | None = None,
|
|
579
|
+
) -> AuditAppendResult:
|
|
580
|
+
"""Append a signed, tamper-evident audit record to the store.
|
|
581
|
+
|
|
582
|
+
Steps:
|
|
583
|
+
|
|
584
|
+
1. Validate *schema_key* against the registry (AUD-002).
|
|
585
|
+
2. Generate a unique ``record_id`` (UUID4).
|
|
586
|
+
3. Compute HMAC-SHA256 signature over ``record_id + canonical_json``.
|
|
587
|
+
4. Write the record to the local store and update the SQLite index.
|
|
588
|
+
5. If *schema_key* is in :data:`_TRUST_FEED_SCHEMAS`, write a
|
|
589
|
+
T.R.U.S.T. summary record (AUD-030).
|
|
590
|
+
6. Return :class:`AuditAppendResult`.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
record: The audit record payload. Must be a ``dict``.
|
|
594
|
+
schema_key: Schema namespace key (e.g. ``"halluccheck.score.v1"``).
|
|
595
|
+
project_id: Optional project scope. Falls back to
|
|
596
|
+
``config.project_id``.
|
|
597
|
+
strict_schema: Override the instance ``strict_schema`` flag for
|
|
598
|
+
this call only.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
:class:`~spanforge.sdk._types.AuditAppendResult`
|
|
602
|
+
|
|
603
|
+
Raises:
|
|
604
|
+
SFAuditSchemaError: Unknown schema key when ``strict_schema=True``.
|
|
605
|
+
SFAuditAppendError: Record payload is not a ``dict``.
|
|
606
|
+
"""
|
|
607
|
+
if not isinstance(record, dict):
|
|
608
|
+
raise SFAuditAppendError(f"record must be a dict; got {type(record).__name__}")
|
|
609
|
+
|
|
610
|
+
effective_strict = strict_schema if strict_schema is not None else self._strict_schema
|
|
611
|
+
if effective_strict and schema_key not in KNOWN_SCHEMA_KEYS:
|
|
612
|
+
raise SFAuditSchemaError(schema_key, KNOWN_SCHEMA_KEYS)
|
|
613
|
+
|
|
614
|
+
effective_project = project_id or self._config.project_id
|
|
615
|
+
|
|
616
|
+
record_id = str(uuid.uuid4())
|
|
617
|
+
timestamp = _utc_now_iso()
|
|
618
|
+
|
|
619
|
+
# Canonical JSON for HMAC
|
|
620
|
+
enriched = {
|
|
621
|
+
"record_id": record_id,
|
|
622
|
+
"schema_key": schema_key,
|
|
623
|
+
"project_id": effective_project,
|
|
624
|
+
"timestamp": timestamp,
|
|
625
|
+
**record,
|
|
626
|
+
}
|
|
627
|
+
canonical = json.dumps(enriched, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
628
|
+
|
|
629
|
+
org_secret = self._config.signing_key or _FALLBACK_SIGNING_KEY
|
|
630
|
+
hmac_value = _compute_record_hmac(record_id, canonical, org_secret)
|
|
631
|
+
|
|
632
|
+
with self._chain_lock:
|
|
633
|
+
position = self._chain_position
|
|
634
|
+
self._chain_position += 1
|
|
635
|
+
self._last_hmac = hmac_value
|
|
636
|
+
|
|
637
|
+
audit_record = _AuditRecord(
|
|
638
|
+
record_id=record_id,
|
|
639
|
+
schema_key=schema_key,
|
|
640
|
+
project_id=effective_project,
|
|
641
|
+
timestamp=timestamp,
|
|
642
|
+
hmac=hmac_value,
|
|
643
|
+
chain_position=position,
|
|
644
|
+
payload=record,
|
|
645
|
+
)
|
|
646
|
+
self._store.append(audit_record)
|
|
647
|
+
|
|
648
|
+
# AUD-030: T.R.U.S.T. store write
|
|
649
|
+
if schema_key in _TRUST_FEED_SCHEMAS:
|
|
650
|
+
trust_rec = _build_trust_record(
|
|
651
|
+
schema_key, record_id, effective_project, record, hmac_value, timestamp
|
|
652
|
+
)
|
|
653
|
+
self._store.append_trust(trust_rec)
|
|
654
|
+
|
|
655
|
+
backend = self._byos_provider or "local"
|
|
656
|
+
_log.debug(
|
|
657
|
+
"sf-audit: appended record_id=%s schema=%s pos=%d backend=%s",
|
|
658
|
+
record_id,
|
|
659
|
+
schema_key,
|
|
660
|
+
position,
|
|
661
|
+
backend,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
return AuditAppendResult(
|
|
665
|
+
record_id=record_id,
|
|
666
|
+
chain_position=position,
|
|
667
|
+
timestamp=timestamp,
|
|
668
|
+
hmac=hmac_value,
|
|
669
|
+
schema_key=schema_key,
|
|
670
|
+
backend=backend,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# ------------------------------------------------------------------
|
|
674
|
+
# AUD-003: sign
|
|
675
|
+
# ------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
def sign(self, record: dict[str, Any]) -> SignedRecord:
|
|
678
|
+
"""Sign a raw dict with HMAC-SHA256.
|
|
679
|
+
|
|
680
|
+
This is the **only** signing call site for HallucCheck — callers
|
|
681
|
+
must not import from :mod:`spanforge.signing` directly.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
record: The record dict to sign.
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
:class:`~spanforge.sdk._types.SignedRecord`
|
|
688
|
+
|
|
689
|
+
Raises:
|
|
690
|
+
SFAuditAppendError: If *record* is not a ``dict``.
|
|
691
|
+
"""
|
|
692
|
+
if not isinstance(record, dict):
|
|
693
|
+
raise SFAuditAppendError(f"sign() requires a dict; got {type(record).__name__}")
|
|
694
|
+
|
|
695
|
+
record_id = str(uuid.uuid4())
|
|
696
|
+
timestamp = _utc_now_iso()
|
|
697
|
+
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
698
|
+
checksum = f"sha256:{hashlib.sha256(canonical.encode()).hexdigest()}"
|
|
699
|
+
|
|
700
|
+
org_secret = self._config.signing_key or _FALLBACK_SIGNING_KEY
|
|
701
|
+
hmac_value = _compute_record_hmac(record_id, canonical, org_secret)
|
|
702
|
+
|
|
703
|
+
return SignedRecord(
|
|
704
|
+
record=dict(record),
|
|
705
|
+
record_id=record_id,
|
|
706
|
+
checksum=checksum,
|
|
707
|
+
signature=hmac_value,
|
|
708
|
+
timestamp=timestamp,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# ------------------------------------------------------------------
|
|
712
|
+
# AUD-004: verify_chain (wraps signing.verify_chain)
|
|
713
|
+
# ------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
def verify_chain(
|
|
716
|
+
self,
|
|
717
|
+
records: list[dict[str, Any]],
|
|
718
|
+
*,
|
|
719
|
+
org_secret: str | None = None,
|
|
720
|
+
) -> dict[str, Any]:
|
|
721
|
+
"""Verify the HMAC chain across a list of signed audit dicts.
|
|
722
|
+
|
|
723
|
+
For each consecutive pair ``(records[n-1], records[n])``, checks:
|
|
724
|
+
|
|
725
|
+
1. ``record[n]["hmac"]`` is valid for ``record[n]["record_id"]`` and its canonical JSON.
|
|
726
|
+
2. The records are in ascending ``chain_position`` order.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
records: Ordered list of audit record dicts (as returned by
|
|
730
|
+
:meth:`export`). Each must have ``record_id``,
|
|
731
|
+
``hmac``, and ``chain_position`` fields.
|
|
732
|
+
org_secret: HMAC key to use for verification. Defaults to
|
|
733
|
+
``config.signing_key``.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
``{"valid": bool, "tampered_count": int, "first_tampered": str|None,
|
|
737
|
+
"gaps": list[str], "verified_count": int}``
|
|
738
|
+
|
|
739
|
+
Raises:
|
|
740
|
+
SFAuditQueryError: If *records* is not a list.
|
|
741
|
+
"""
|
|
742
|
+
if not isinstance(records, list):
|
|
743
|
+
raise SFAuditQueryError(
|
|
744
|
+
f"verify_chain() requires a list of dicts; got {type(records).__name__}"
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
secret = org_secret or self._config.signing_key or _FALLBACK_SIGNING_KEY
|
|
748
|
+
|
|
749
|
+
first_tampered: str | None = None
|
|
750
|
+
tampered_count = 0
|
|
751
|
+
gaps: list[str] = []
|
|
752
|
+
prev_position: int | None = None
|
|
753
|
+
|
|
754
|
+
for rec in records:
|
|
755
|
+
if not isinstance(rec, dict):
|
|
756
|
+
tampered_count += 1
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
rid = rec.get("record_id", "")
|
|
760
|
+
stored_hmac = rec.get("hmac", "")
|
|
761
|
+
|
|
762
|
+
# Re-derive canonical JSON — exclude fields not in the original HMAC input
|
|
763
|
+
payload = {k: v for k, v in rec.items() if k not in ("hmac", "chain_position")}
|
|
764
|
+
canonical = json.dumps(
|
|
765
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
766
|
+
)
|
|
767
|
+
expected_hmac = _compute_record_hmac(rid, canonical, secret)
|
|
768
|
+
|
|
769
|
+
valid = _hmac.compare_digest(stored_hmac, expected_hmac)
|
|
770
|
+
if not valid:
|
|
771
|
+
tampered_count += 1
|
|
772
|
+
if first_tampered is None:
|
|
773
|
+
first_tampered = rid
|
|
774
|
+
|
|
775
|
+
pos = rec.get("chain_position")
|
|
776
|
+
if pos is not None and prev_position is not None and int(pos) != int(prev_position) + 1:
|
|
777
|
+
gaps.append(rid)
|
|
778
|
+
prev_position = pos
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
"valid": tampered_count == 0 and not gaps,
|
|
782
|
+
"tampered_count": tampered_count,
|
|
783
|
+
"first_tampered": first_tampered,
|
|
784
|
+
"gaps": gaps,
|
|
785
|
+
"verified_count": len(records),
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
# ------------------------------------------------------------------
|
|
789
|
+
# AUD-005: export
|
|
790
|
+
# ------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
def export(
|
|
793
|
+
self,
|
|
794
|
+
schema_key: str | None = None,
|
|
795
|
+
*,
|
|
796
|
+
date_range: tuple[str | None, str | None] | None = None,
|
|
797
|
+
project_id: str | None = None,
|
|
798
|
+
limit: int = 10_000,
|
|
799
|
+
) -> list[dict[str, Any]]:
|
|
800
|
+
"""Query audit records by schema key, date range, and project.
|
|
801
|
+
|
|
802
|
+
Uses the SQLite index for O(log n) date-range access without a full
|
|
803
|
+
file scan. Falls back to linear scan when the index is unavailable.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
schema_key: Filter to a specific schema key, or ``None`` for all.
|
|
807
|
+
date_range: Optional ``(from_iso, to_iso)`` tuple. Either element
|
|
808
|
+
may be ``None`` for open-ended ranges. ISO-8601 UTC.
|
|
809
|
+
project_id: Filter to a specific project, or ``None`` for all.
|
|
810
|
+
limit: Maximum records to return (default: 10 000).
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
List of audit record dicts, sorted by ``timestamp`` ascending.
|
|
814
|
+
|
|
815
|
+
Raises:
|
|
816
|
+
SFAuditQueryError: If *schema_key* is provided and does not match
|
|
817
|
+
any known schema (when ``strict_schema=True``).
|
|
818
|
+
"""
|
|
819
|
+
if schema_key is not None and self._strict_schema and schema_key not in KNOWN_SCHEMA_KEYS:
|
|
820
|
+
raise SFAuditQueryError(
|
|
821
|
+
f"Unknown schema_key {schema_key!r} for export query. "
|
|
822
|
+
"Pass strict_schema=False or use a known schema key."
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
from_ts: str | None = None
|
|
826
|
+
to_ts: str | None = None
|
|
827
|
+
if date_range:
|
|
828
|
+
from_ts, to_ts = date_range[0], date_range[1]
|
|
829
|
+
|
|
830
|
+
results = self._store.query(schema_key, project_id, from_ts, to_ts)
|
|
831
|
+
return results[:limit]
|
|
832
|
+
|
|
833
|
+
# ------------------------------------------------------------------
|
|
834
|
+
# AUD-030 / AUD-031: T.R.U.S.T. scorecard
|
|
835
|
+
# ------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
def get_trust_scorecard(
|
|
838
|
+
self,
|
|
839
|
+
project_id: str | None = None,
|
|
840
|
+
*,
|
|
841
|
+
from_dt: str | None = None,
|
|
842
|
+
to_dt: str | None = None,
|
|
843
|
+
) -> TrustScorecard:
|
|
844
|
+
"""Return the aggregated T.R.U.S.T. scorecard for *project_id*.
|
|
845
|
+
|
|
846
|
+
Aggregates all T.R.U.S.T. summary records appended via
|
|
847
|
+
:meth:`append` and computes per-dimension scores with trend signals.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
project_id: Scoping project. Defaults to ``config.project_id``.
|
|
851
|
+
from_dt: ISO-8601 UTC start of reporting window.
|
|
852
|
+
to_dt: ISO-8601 UTC end of reporting window.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
:class:`~spanforge.sdk._types.TrustScorecard`
|
|
856
|
+
"""
|
|
857
|
+
effective_project = project_id or self._config.project_id
|
|
858
|
+
now_iso = _utc_now_iso()
|
|
859
|
+
from_iso = from_dt or "1970-01-01T00:00:00.000000Z"
|
|
860
|
+
to_iso = to_dt or now_iso
|
|
861
|
+
|
|
862
|
+
trust_records = self._store.query_trust(effective_project or None, from_iso, to_iso)
|
|
863
|
+
|
|
864
|
+
# Bucket records by dimension
|
|
865
|
+
by_dim: dict[str, list[dict[str, Any]]] = {d: [] for d in _DIMENSION_NAMES}
|
|
866
|
+
for rec in trust_records:
|
|
867
|
+
dim = rec.get("trust_dimension", "compliance_posture")
|
|
868
|
+
if dim in by_dim:
|
|
869
|
+
by_dim[dim].append(rec)
|
|
870
|
+
|
|
871
|
+
def _dim(name: str) -> TrustDimension:
|
|
872
|
+
recs = by_dim[name]
|
|
873
|
+
score, trend = _compute_dimension_score(recs)
|
|
874
|
+
last_rec = recs[-1] if recs else None
|
|
875
|
+
last_ts = last_rec["timestamp"] if last_rec else now_iso
|
|
876
|
+
return TrustDimension(score=score, trend=trend, last_updated=last_ts)
|
|
877
|
+
|
|
878
|
+
return TrustScorecard(
|
|
879
|
+
project_id=effective_project,
|
|
880
|
+
from_dt=from_iso,
|
|
881
|
+
to_dt=to_iso,
|
|
882
|
+
hallucination=_dim("hallucination"),
|
|
883
|
+
pii_hygiene=_dim("pii_hygiene"),
|
|
884
|
+
secrets_hygiene=_dim("secrets_hygiene"),
|
|
885
|
+
gate_pass_rate=_dim("gate_pass_rate"),
|
|
886
|
+
compliance_posture=_dim("compliance_posture"),
|
|
887
|
+
record_count=len(trust_records),
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# ------------------------------------------------------------------
|
|
891
|
+
# AUD-042: generate_article30_record (GDPR)
|
|
892
|
+
# ------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
def generate_article30_record(
|
|
895
|
+
self,
|
|
896
|
+
project_id: str | None = None,
|
|
897
|
+
*,
|
|
898
|
+
controller_name: str = "Data Controller",
|
|
899
|
+
processor_name: str = "SpanForge (AI Observability Platform)",
|
|
900
|
+
third_country: bool = False,
|
|
901
|
+
retention_period: str | None = None,
|
|
902
|
+
) -> Article30Record:
|
|
903
|
+
"""Generate a GDPR Article 30 Record of Processing Activities (RoPA).
|
|
904
|
+
|
|
905
|
+
Produces a structured document conforming to GDPR Article 30 §1
|
|
906
|
+
requirements, based on the audit metadata for *project_id*.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
project_id: Scoping project. Defaults to ``config.project_id``.
|
|
910
|
+
controller_name: Name of the data controller.
|
|
911
|
+
processor_name: Name of the processor (default: SpanForge).
|
|
912
|
+
third_country: Whether data is transferred to a third country.
|
|
913
|
+
retention_period: Override the default retention description.
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
:class:`~spanforge.sdk._types.Article30Record`
|
|
917
|
+
"""
|
|
918
|
+
effective_project = project_id or self._config.project_id
|
|
919
|
+
now_iso = _utc_now_iso()
|
|
920
|
+
|
|
921
|
+
soc2_ref = "SOC 2 / HIPAA / GDPR Article 5(1)(e)"
|
|
922
|
+
ret_desc = retention_period or (
|
|
923
|
+
f"{self._retention_years} years (WORM-compliant, per {soc2_ref})"
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
return Article30Record(
|
|
927
|
+
project_id=effective_project,
|
|
928
|
+
controller_name=controller_name,
|
|
929
|
+
processor_name=processor_name,
|
|
930
|
+
processing_purposes=[
|
|
931
|
+
"AI output quality assurance and hallucination detection",
|
|
932
|
+
"PII detection and redaction for GDPR/DPDP/HIPAA compliance",
|
|
933
|
+
"Secrets scanning and data leakage prevention",
|
|
934
|
+
"Audit logging for regulatory compliance evidence",
|
|
935
|
+
"Model drift and bias monitoring",
|
|
936
|
+
],
|
|
937
|
+
data_categories=[
|
|
938
|
+
"AI-generated text outputs",
|
|
939
|
+
"Model request/response metadata",
|
|
940
|
+
"PII detection results (entity types only — no raw PII stored)",
|
|
941
|
+
"Audit chain integrity hashes",
|
|
942
|
+
],
|
|
943
|
+
data_subjects=[
|
|
944
|
+
"End users of AI-powered applications",
|
|
945
|
+
"Application operators",
|
|
946
|
+
],
|
|
947
|
+
recipients=[
|
|
948
|
+
"Data controller (internal audit teams)",
|
|
949
|
+
"External auditors (under NDA / DPA)",
|
|
950
|
+
"Regulatory authorities (upon lawful request)",
|
|
951
|
+
],
|
|
952
|
+
third_country=third_country,
|
|
953
|
+
retention_period=ret_desc,
|
|
954
|
+
security_measures=[
|
|
955
|
+
"HMAC-SHA256 tamper-evident audit chain",
|
|
956
|
+
"WORM (Write-Once-Read-Many) append-only storage",
|
|
957
|
+
"TLS 1.2+ encryption in transit",
|
|
958
|
+
"AES-256 encryption at rest (BYOS provider dependent)",
|
|
959
|
+
"Role-based access control via sf-identity",
|
|
960
|
+
"SOC 2 Type II, ISO 27001-aligned controls",
|
|
961
|
+
],
|
|
962
|
+
generated_at=now_iso,
|
|
963
|
+
record_id=str(uuid.uuid4()),
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# ------------------------------------------------------------------
|
|
967
|
+
# Status endpoint contribution
|
|
968
|
+
# ------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
def get_status(self) -> AuditStatusInfo:
|
|
971
|
+
"""Return sf-audit service health information.
|
|
972
|
+
|
|
973
|
+
Contributes to ``GET /v1/spanforge/status``.
|
|
974
|
+
|
|
975
|
+
Returns:
|
|
976
|
+
:class:`~spanforge.sdk._types.AuditStatusInfo`
|
|
977
|
+
"""
|
|
978
|
+
return AuditStatusInfo(
|
|
979
|
+
status="ok",
|
|
980
|
+
backend=self._byos_provider or "local",
|
|
981
|
+
byos_enabled=self._byos_provider is not None,
|
|
982
|
+
record_count=self._store.record_count,
|
|
983
|
+
last_append_at=self._store.last_append_at,
|
|
984
|
+
schema_count=len(KNOWN_SCHEMA_KEYS),
|
|
985
|
+
index_healthy=self._store.index_healthy,
|
|
986
|
+
retention_years=self._retention_years,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# ------------------------------------------------------------------
|
|
990
|
+
# Lifecycle
|
|
991
|
+
# ------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
async def append_async(
|
|
994
|
+
self,
|
|
995
|
+
record: dict[str, Any],
|
|
996
|
+
schema_key: str,
|
|
997
|
+
*,
|
|
998
|
+
project_id: str = "",
|
|
999
|
+
strict_schema: bool | None = None,
|
|
1000
|
+
) -> AuditAppendResult:
|
|
1001
|
+
"""Async variant of :meth:`append`.
|
|
1002
|
+
|
|
1003
|
+
Runs the append (including HMAC computation and SQLite write) in the
|
|
1004
|
+
default executor so the event loop is not blocked.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
record: The audit record payload.
|
|
1008
|
+
schema_key: Schema namespace key.
|
|
1009
|
+
project_id: Optional project scope.
|
|
1010
|
+
strict_schema: Override the instance ``strict_schema`` flag.
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
:class:`~spanforge.sdk._types.AuditAppendResult`.
|
|
1014
|
+
"""
|
|
1015
|
+
import asyncio
|
|
1016
|
+
import functools
|
|
1017
|
+
|
|
1018
|
+
loop = asyncio.get_event_loop()
|
|
1019
|
+
return await loop.run_in_executor(
|
|
1020
|
+
None,
|
|
1021
|
+
functools.partial(
|
|
1022
|
+
self.append,
|
|
1023
|
+
record,
|
|
1024
|
+
schema_key,
|
|
1025
|
+
project_id=project_id,
|
|
1026
|
+
strict_schema=strict_schema,
|
|
1027
|
+
),
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# ------------------------------------------------------------------
|
|
1031
|
+
# AUD-RFC3161: RFC 3161 Trusted Timestamp Authority integration
|
|
1032
|
+
# ------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
#: Public TSA endpoints used by :meth:`stamp_with_tsa`.
|
|
1035
|
+
TSA_ENDPOINTS: tuple[str, ...] = (
|
|
1036
|
+
"http://timestamp.digicert.com",
|
|
1037
|
+
"https://freetsa.org/tsr",
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
def stamp_with_tsa(
|
|
1041
|
+
self,
|
|
1042
|
+
record_data: dict[str, Any],
|
|
1043
|
+
*,
|
|
1044
|
+
tsa_url: str | None = None,
|
|
1045
|
+
timeout_seconds: int = 10,
|
|
1046
|
+
) -> dict[str, Any]:
|
|
1047
|
+
"""Obtain an RFC 3161 trusted timestamp for *record_data*.
|
|
1048
|
+
|
|
1049
|
+
Builds a minimal RFC 3161 ``TimeStampReq`` message (hash-only, no
|
|
1050
|
+
nonce extension), POSTs it to a qualified Timestamp Authority, and
|
|
1051
|
+
returns a structured result containing the TSA URL, the SHA-256 hash
|
|
1052
|
+
of the canonicalised record, and the raw DER response bytes encoded
|
|
1053
|
+
as hexadecimal.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
record_data: The audit record dict to timestamp. The SHA-256
|
|
1057
|
+
hash of its canonical JSON is sent to the TSA.
|
|
1058
|
+
tsa_url: Override the default TSA URL. If ``None``, the
|
|
1059
|
+
first reachable URL in :attr:`TSA_ENDPOINTS` is
|
|
1060
|
+
used.
|
|
1061
|
+
timeout_seconds: HTTP request timeout in seconds (default: 10).
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
``{"tsa_url": str, "hash_algorithm": "sha256", "message_imprint": str,
|
|
1065
|
+
"tsr_hex": str, "stamped_at": str}``
|
|
1066
|
+
|
|
1067
|
+
Raises:
|
|
1068
|
+
SFAuditAppendError: If the TSA returns a non-200 HTTP status or the
|
|
1069
|
+
response body is empty.
|
|
1070
|
+
SFAuditAppendError: If *record_data* is not a dict.
|
|
1071
|
+
|
|
1072
|
+
Note:
|
|
1073
|
+
This method makes a real outbound HTTPS request. In test
|
|
1074
|
+
environments, patch :func:`urllib.request.urlopen` to avoid
|
|
1075
|
+
network calls — the request format is validated by the unit tests.
|
|
1076
|
+
"""
|
|
1077
|
+
import struct
|
|
1078
|
+
import urllib.error
|
|
1079
|
+
import urllib.request
|
|
1080
|
+
|
|
1081
|
+
if not isinstance(record_data, dict):
|
|
1082
|
+
raise SFAuditAppendError(
|
|
1083
|
+
f"stamp_with_tsa() requires a dict; got {type(record_data).__name__}"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# 1. Compute SHA-256 of canonical JSON
|
|
1087
|
+
canonical = json.dumps(
|
|
1088
|
+
record_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
1089
|
+
)
|
|
1090
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).digest()
|
|
1091
|
+
message_imprint_hex = digest.hex()
|
|
1092
|
+
|
|
1093
|
+
# 2. Build a minimal RFC 3161 TimeStampReq in DER encoding.
|
|
1094
|
+
# Structure (simplified, hash-only, no nonce, no certReq):
|
|
1095
|
+
# SEQUENCE {
|
|
1096
|
+
# INTEGER 1 -- version
|
|
1097
|
+
# SEQUENCE { -- messageImprint
|
|
1098
|
+
# SEQUENCE { -- hashAlgorithm (sha256 OID)
|
|
1099
|
+
# OID 2.16.840.1.101.3.4.2.1
|
|
1100
|
+
# NULL
|
|
1101
|
+
# }
|
|
1102
|
+
# OCTET STRING <32-byte digest>
|
|
1103
|
+
# }
|
|
1104
|
+
# }
|
|
1105
|
+
sha256_oid = bytes([
|
|
1106
|
+
0x30, 0x0d, # SEQUENCE (13 bytes)
|
|
1107
|
+
0x06, 0x09, # OID (9 bytes)
|
|
1108
|
+
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, # sha-256
|
|
1109
|
+
0x05, 0x00, # NULL
|
|
1110
|
+
])
|
|
1111
|
+
octet_string = bytes([0x04, 0x20]) + digest # OCTET STRING (32 bytes)
|
|
1112
|
+
message_imprint = bytes([0x30]) + _der_length(len(sha256_oid) + len(octet_string)) + sha256_oid + octet_string
|
|
1113
|
+
version = bytes([0x02, 0x01, 0x01]) # INTEGER 1
|
|
1114
|
+
tsa_req_body = version + message_imprint
|
|
1115
|
+
tsa_req = bytes([0x30]) + _der_length(len(tsa_req_body)) + tsa_req_body
|
|
1116
|
+
|
|
1117
|
+
# 3. POST to TSA
|
|
1118
|
+
url = tsa_url or self.TSA_ENDPOINTS[0]
|
|
1119
|
+
req = urllib.request.Request(
|
|
1120
|
+
url,
|
|
1121
|
+
data=tsa_req,
|
|
1122
|
+
headers={"Content-Type": "application/timestamp-query"},
|
|
1123
|
+
method="POST",
|
|
1124
|
+
)
|
|
1125
|
+
try:
|
|
1126
|
+
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # nosec B310
|
|
1127
|
+
status = resp.getcode()
|
|
1128
|
+
body = resp.read()
|
|
1129
|
+
except urllib.error.URLError as exc:
|
|
1130
|
+
raise SFAuditAppendError(
|
|
1131
|
+
f"RFC 3161 TSA request to {url!r} failed: {exc}"
|
|
1132
|
+
) from exc
|
|
1133
|
+
|
|
1134
|
+
if status != 200 or not body:
|
|
1135
|
+
raise SFAuditAppendError(
|
|
1136
|
+
f"RFC 3161 TSA returned HTTP {status} from {url!r}"
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
"tsa_url": url,
|
|
1141
|
+
"hash_algorithm": "sha256",
|
|
1142
|
+
"message_imprint": message_imprint_hex,
|
|
1143
|
+
"tsr_hex": body.hex(),
|
|
1144
|
+
"stamped_at": _utc_now_iso(),
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
def verify_tsa_timestamp(
|
|
1148
|
+
self,
|
|
1149
|
+
tsa_result: dict[str, Any],
|
|
1150
|
+
record_data: dict[str, Any],
|
|
1151
|
+
) -> dict[str, Any]:
|
|
1152
|
+
"""Verify that *tsa_result* (from :meth:`stamp_with_tsa`) matches *record_data*.
|
|
1153
|
+
|
|
1154
|
+
Validates that the ``message_imprint`` in *tsa_result* matches the
|
|
1155
|
+
SHA-256 hash of the canonical JSON of *record_data*, and that the
|
|
1156
|
+
``tsr_hex`` field is non-empty (i.e. the TSR was actually received).
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
tsa_result: The dict returned by :meth:`stamp_with_tsa`.
|
|
1160
|
+
record_data: The audit record dict that was stamped.
|
|
1161
|
+
|
|
1162
|
+
Returns:
|
|
1163
|
+
``{"valid": bool, "reason": str}``
|
|
1164
|
+
"""
|
|
1165
|
+
if not isinstance(tsa_result, dict) or not isinstance(record_data, dict):
|
|
1166
|
+
return {"valid": False, "reason": "tsa_result and record_data must be dicts"}
|
|
1167
|
+
|
|
1168
|
+
required = {"tsa_url", "message_imprint", "tsr_hex"}
|
|
1169
|
+
if not required.issubset(tsa_result.keys()):
|
|
1170
|
+
missing = required - tsa_result.keys()
|
|
1171
|
+
return {"valid": False, "reason": f"tsa_result missing fields: {missing}"}
|
|
1172
|
+
|
|
1173
|
+
canonical = json.dumps(
|
|
1174
|
+
record_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
1175
|
+
)
|
|
1176
|
+
expected_imprint = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
1177
|
+
|
|
1178
|
+
if not _hmac.compare_digest(
|
|
1179
|
+
tsa_result["message_imprint"].lower(), expected_imprint.lower()
|
|
1180
|
+
):
|
|
1181
|
+
return {
|
|
1182
|
+
"valid": False,
|
|
1183
|
+
"reason": "message_imprint mismatch — record may have been tampered",
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if not tsa_result.get("tsr_hex"):
|
|
1187
|
+
return {"valid": False, "reason": "tsr_hex is empty — no TSA response was stored"}
|
|
1188
|
+
|
|
1189
|
+
return {"valid": True, "reason": "TSA timestamp verified"}
|
|
1190
|
+
|
|
1191
|
+
def close(self) -> None:
|
|
1192
|
+
"""Release SQLite resources and optionally clean up the temp index file."""
|
|
1193
|
+
self._store.close()
|
|
1194
|
+
if not self._persist_index:
|
|
1195
|
+
with contextlib.suppress(Exception): # pragma: no cover
|
|
1196
|
+
Path(self._db_path).unlink(missing_ok=True)
|