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
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
"""spanforge.core.compliance_mapping — Compliance evidence engine.
|
|
2
|
+
|
|
3
|
+
Maps spanforge telemetry events to regulatory framework clauses and produces
|
|
4
|
+
signed attestation packages suitable for audit submission.
|
|
5
|
+
|
|
6
|
+
Supported frameworks
|
|
7
|
+
--------------------
|
|
8
|
+
* soc2 — SOC 2 Type II (CC series)
|
|
9
|
+
* hipaa — HIPAA Security Rule
|
|
10
|
+
* gdpr — GDPR (EU) 2016/679
|
|
11
|
+
* nist_ai_rmf — NIST AI Risk Management Framework 1.0
|
|
12
|
+
* eu_ai_act — EU AI Act (Annex IV documentation requirements)
|
|
13
|
+
* iso_42001 — ISO/IEC 42001:2023 AI Management System
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import enum
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac as _hmac
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ClauseStatus",
|
|
30
|
+
"ComplianceAttestation",
|
|
31
|
+
"ComplianceEvidencePackage",
|
|
32
|
+
"ComplianceFramework",
|
|
33
|
+
"ComplianceMappingEngine",
|
|
34
|
+
"EvidenceRecord",
|
|
35
|
+
"GapReport",
|
|
36
|
+
"verify_attestation_signature",
|
|
37
|
+
"verify_pdf_attestation",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("spanforge.core.compliance_mapping")
|
|
41
|
+
|
|
42
|
+
# Fallback signing key used when SPANFORGE_SIGNING_KEY is absent. Only
|
|
43
|
+
# safe for development / CI — never use in production.
|
|
44
|
+
_INSECURE_DEFAULT_KEY: str = "spanforge-insecure-default-do-not-use-in-production"
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Framework enum
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ComplianceFramework(enum.Enum):
|
|
52
|
+
"""Supported regulatory frameworks."""
|
|
53
|
+
|
|
54
|
+
SOC2 = "SOC 2 Type II"
|
|
55
|
+
HIPAA = "HIPAA"
|
|
56
|
+
GDPR = "GDPR"
|
|
57
|
+
NIST_AI_RMF = "NIST AI RMF"
|
|
58
|
+
EU_AI_ACT = "EU AI Act"
|
|
59
|
+
ISO_42001 = "ISO/IEC 42001"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Maps enum value strings and slug strings to _FRAMEWORK_CLAUSES keys
|
|
63
|
+
_FRAMEWORK_KEY_MAP: dict[str, str] = {
|
|
64
|
+
# enum values
|
|
65
|
+
"soc 2 type ii": "soc2",
|
|
66
|
+
"hipaa": "hipaa",
|
|
67
|
+
"gdpr": "gdpr",
|
|
68
|
+
"nist ai rmf": "nist_ai_rmf",
|
|
69
|
+
"eu ai act": "eu_ai_act",
|
|
70
|
+
"iso/iec 42001": "iso_42001",
|
|
71
|
+
# slugs (already match keys, but listed for completeness)
|
|
72
|
+
"soc2": "soc2",
|
|
73
|
+
"nist_ai_rmf": "nist_ai_rmf",
|
|
74
|
+
"eu_ai_act": "eu_ai_act",
|
|
75
|
+
"iso_42001": "iso_42001",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Framework → clause definitions
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# Each clause maps to a list of event-type prefixes that provide evidence.
|
|
84
|
+
_FRAMEWORK_CLAUSES: dict[str, dict[str, dict[str, Any]]] = {
|
|
85
|
+
"soc2": {
|
|
86
|
+
"CC6.1": {
|
|
87
|
+
"title": "Logical and Physical Access Controls — access management",
|
|
88
|
+
"event_prefixes": ["llm.audit.", "llm.trace.", "model_registry."],
|
|
89
|
+
"description": "Events demonstrating actor-based access controls, audit trails, and model lifecycle tracking.",
|
|
90
|
+
"min_event_count": 5,
|
|
91
|
+
"time_window_hours": None,
|
|
92
|
+
"remediation_steps": (
|
|
93
|
+
"Tag every LLM call with an actor identity: `spanforge.configure(actor_id='<user-id>')`. "
|
|
94
|
+
"Emit audit records via `sf_audit.record()`. "
|
|
95
|
+
"Register models in the model registry: `sf_model_registry.register()`."
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
"CC6.6": {
|
|
99
|
+
"title": "PII / Sensitive Data Protection",
|
|
100
|
+
"event_prefixes": ["llm.redact."],
|
|
101
|
+
"description": "Evidence of PII detection and redaction before model transmission.",
|
|
102
|
+
"min_event_count": 5,
|
|
103
|
+
"time_window_hours": None,
|
|
104
|
+
"remediation_steps": (
|
|
105
|
+
"Enable PII redaction: `spanforge.configure(redact_pii=True)`. "
|
|
106
|
+
"Ensure `llm.redact.*` events are being emitted on every LLM call that may carry personal data."
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
"CC7.2": {
|
|
110
|
+
"title": "Anomaly and Threat Detection",
|
|
111
|
+
"event_prefixes": ["llm.drift.", "llm.guard."],
|
|
112
|
+
"description": "Drift detection and guard-rail events demonstrating anomaly monitoring.",
|
|
113
|
+
"min_event_count": 5,
|
|
114
|
+
"time_window_hours": None,
|
|
115
|
+
"remediation_steps": (
|
|
116
|
+
"Enable drift monitoring: `spanforge.configure(drift_detection=True)`. "
|
|
117
|
+
"Add policy gates to your pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
"CC8.1": {
|
|
121
|
+
"title": "Change Management — schema validation",
|
|
122
|
+
"event_prefixes": ["llm.trace.", "llm.eval."],
|
|
123
|
+
"description": "Schema-validated telemetry providing a tamper-evident event chain.",
|
|
124
|
+
"min_event_count": 5,
|
|
125
|
+
"time_window_hours": None,
|
|
126
|
+
"remediation_steps": (
|
|
127
|
+
"Ensure `spanforge.configure()` is called at startup so trace events are emitted. "
|
|
128
|
+
"Add evaluation tracking: `spanforge.configure(track_eval=True)`. "
|
|
129
|
+
"Set `SPANFORGE_SIGNING_KEY` for tamper-evident chain integrity."
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
"CC9.2": {
|
|
133
|
+
"title": "Risk Mitigation — cost and budget controls",
|
|
134
|
+
"event_prefixes": ["llm.cost."],
|
|
135
|
+
"description": "Cost budget and spend telemetry supporting financial risk controls.",
|
|
136
|
+
"min_event_count": 5,
|
|
137
|
+
"time_window_hours": None,
|
|
138
|
+
"remediation_steps": (
|
|
139
|
+
"Enable cost tracking: `spanforge.configure(track_cost=True)`. "
|
|
140
|
+
"Set budget alerts: `sf_alert.configure(budget_usd=100)`. "
|
|
141
|
+
"Ensure `llm.cost.*` events are emitted per LLM call."
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
"hipaa": {
|
|
146
|
+
"164.312(b)": {
|
|
147
|
+
"title": "Audit Controls",
|
|
148
|
+
"event_prefixes": ["llm.audit.", "llm.trace."],
|
|
149
|
+
"description": "HMAC-signed event chain providing tamper-evident audit record of PHI activity.",
|
|
150
|
+
"min_event_count": 10,
|
|
151
|
+
"time_window_hours": None,
|
|
152
|
+
"remediation_steps": (
|
|
153
|
+
"Call `sf_audit.record()` on every LLM call that touches PHI. "
|
|
154
|
+
"Set `SPANFORGE_SIGNING_KEY` to enable HMAC chain integrity. "
|
|
155
|
+
"Configure a durable exporter: `spanforge.configure(exporter='sqlite')` or `exporter='jsonl'`."
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
"164.312(a)(1)": {
|
|
159
|
+
"title": "Access Control",
|
|
160
|
+
"event_prefixes": ["llm.trace.", "llm.audit."],
|
|
161
|
+
"description": "Actor-tagged events demonstrating user/system access to PHI workloads.",
|
|
162
|
+
"min_event_count": 5,
|
|
163
|
+
"time_window_hours": None,
|
|
164
|
+
"remediation_steps": (
|
|
165
|
+
"Tag all PHI workloads with an actor identity: `sf_identity.bind_actor(user_id='...')`. "
|
|
166
|
+
"Ensure `actor_id` is present on every `llm.trace.*` event."
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
"164.312(e)(2)(ii)": {
|
|
170
|
+
"title": "Encryption and Decryption",
|
|
171
|
+
"event_prefixes": ["llm.redact."],
|
|
172
|
+
"description": "PII redaction events demonstrating PHI de-identification before model use.",
|
|
173
|
+
"min_event_count": 5,
|
|
174
|
+
"time_window_hours": None,
|
|
175
|
+
"remediation_steps": (
|
|
176
|
+
"Enable PII/PHI redaction: `spanforge.configure(redact_pii=True)`. "
|
|
177
|
+
"For HIPAA workloads, verify that Presidio (or the built-in redactor) is active: "
|
|
178
|
+
"`spanforge doctor` will show PII engine status."
|
|
179
|
+
),
|
|
180
|
+
},
|
|
181
|
+
"164.530(j)": {
|
|
182
|
+
"title": "Documentation and Retention",
|
|
183
|
+
"event_prefixes": ["llm.trace.", "llm.audit.", "llm.cost."],
|
|
184
|
+
"description": "Complete event log supporting required 6-year audit retention.",
|
|
185
|
+
"min_event_count": 10,
|
|
186
|
+
"time_window_hours": 168,
|
|
187
|
+
"remediation_steps": (
|
|
188
|
+
"Configure a durable exporter: `spanforge.configure(exporter='sqlite', endpoint='./spanforge.db')` "
|
|
189
|
+
"or `exporter='jsonl'` for long-term retention. "
|
|
190
|
+
"For 6-year HIPAA retention, forward events to your SIEM or object storage."
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
"gdpr": {
|
|
195
|
+
"Art.30": {
|
|
196
|
+
"title": "Records of Processing Activities",
|
|
197
|
+
"event_prefixes": ["llm.trace.", "llm.cost.", "llm.audit."],
|
|
198
|
+
"description": "Structured event log mapping to Article 30 processing record requirements.",
|
|
199
|
+
"min_event_count": 10,
|
|
200
|
+
"time_window_hours": None,
|
|
201
|
+
"remediation_steps": (
|
|
202
|
+
"Ensure `spanforge.configure()` is called at startup to emit `llm.trace.*` events. "
|
|
203
|
+
"Enable cost tracking to record processing scope. "
|
|
204
|
+
"Set `SPANFORGE_SIGNING_KEY` to produce a tamper-evident Article 30 record."
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
"Art.35": {
|
|
208
|
+
"title": "Data Protection Impact Assessment",
|
|
209
|
+
"event_prefixes": ["llm.redact.", "llm.guard.", "llm.drift."],
|
|
210
|
+
"description": "Redaction, guard-rail, and drift events supporting DPIA risk evidence.",
|
|
211
|
+
"min_event_count": 5,
|
|
212
|
+
"time_window_hours": None,
|
|
213
|
+
"remediation_steps": (
|
|
214
|
+
"Enable PII redaction and drift monitoring: "
|
|
215
|
+
"`spanforge.configure(redact_pii=True, drift_detection=True)`. "
|
|
216
|
+
"Add guard-rail policies: `sf_gate.configure(policy_file='policy.yaml')`."
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
"Art.22": {
|
|
220
|
+
"title": "Automated Individual Decision-Making — consent and oversight",
|
|
221
|
+
"event_prefixes": ["consent.", "hitl."],
|
|
222
|
+
"description": "Consent boundary and human-in-the-loop events demonstrating safeguards for automated decisions affecting individuals.",
|
|
223
|
+
"min_event_count": 5,
|
|
224
|
+
"time_window_hours": None,
|
|
225
|
+
"remediation_steps": (
|
|
226
|
+
"Integrate consent gating before automated decisions: `sf_consent.check(user_id='...')`. "
|
|
227
|
+
"Add human-in-the-loop reviews for high-risk decisions: `sf_hitl.review(trace_id='...')`."
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
"Art.25": {
|
|
231
|
+
"title": "Data Protection by Design and by Default",
|
|
232
|
+
"event_prefixes": ["llm.redact.", "consent."],
|
|
233
|
+
"description": "PII stripping and consent enforcement at instrumentation level demonstrates privacy-by-design.",
|
|
234
|
+
"min_event_count": 5,
|
|
235
|
+
"time_window_hours": None,
|
|
236
|
+
"remediation_steps": (
|
|
237
|
+
"Enable privacy-by-design at the instrumentation layer: `spanforge.configure(redact_pii=True)`. "
|
|
238
|
+
"Enforce consent boundaries on every data subject interaction: `sf_consent.check()`."
|
|
239
|
+
),
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
"nist_ai_rmf": {
|
|
243
|
+
"MAP.1.1": {
|
|
244
|
+
"title": "AI System Documentation",
|
|
245
|
+
"event_prefixes": ["llm.trace.", "llm.eval.", "model_registry.", "explanation."],
|
|
246
|
+
"description": "Trace, evaluation, model registry, and explainability events documenting AI system behaviour.",
|
|
247
|
+
"min_event_count": 5,
|
|
248
|
+
"time_window_hours": None,
|
|
249
|
+
"remediation_steps": (
|
|
250
|
+
"Register your model: `sf_model_registry.register(model_id='...', owner='...', risk_tier='high')`. "
|
|
251
|
+
"Enable eval tracking: `spanforge.configure(track_eval=True)`. "
|
|
252
|
+
"Integrate explainability: `sf_explain.explain(trace_id='...')`."
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
"MEASURE.2.6": {
|
|
256
|
+
"title": "AI Risk Monitoring",
|
|
257
|
+
"event_prefixes": ["llm.drift.", "llm.guard.", "llm.eval."],
|
|
258
|
+
"description": "Drift and guard events demonstrating continuous risk monitoring.",
|
|
259
|
+
"min_event_count": 5,
|
|
260
|
+
"time_window_hours": None,
|
|
261
|
+
"remediation_steps": (
|
|
262
|
+
"Enable drift detection: `spanforge.configure(drift_detection=True)`. "
|
|
263
|
+
"Add policy gate guards to your AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
"MANAGE.3.2": {
|
|
267
|
+
"title": "Incident Response",
|
|
268
|
+
"event_prefixes": ["llm.guard.", "llm.audit."],
|
|
269
|
+
"description": "Guard and audit events providing evidence of incident detection and response.",
|
|
270
|
+
"min_event_count": 5,
|
|
271
|
+
"time_window_hours": None,
|
|
272
|
+
"remediation_steps": (
|
|
273
|
+
"Configure gate alerts for policy violations: `sf_alert.configure(on_guard_trip=True)`. "
|
|
274
|
+
"Call `sf_audit.record()` on every guard trip to produce an incident audit trail."
|
|
275
|
+
),
|
|
276
|
+
},
|
|
277
|
+
"GOVERN.1.7": {
|
|
278
|
+
"title": "AI Policies and Processes",
|
|
279
|
+
"event_prefixes": ["llm.audit.", "llm.trace."],
|
|
280
|
+
"description": "Audit chain demonstrating policy enforcement in AI pipelines.",
|
|
281
|
+
"min_event_count": 5,
|
|
282
|
+
"time_window_hours": None,
|
|
283
|
+
"remediation_steps": (
|
|
284
|
+
"Define and enforce AI policies via gate configuration: `sf_gate.configure(policy_file='policy.yaml')`. "
|
|
285
|
+
"Emit audit records on every policy enforcement decision: `sf_audit.record()`."
|
|
286
|
+
),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
"eu_ai_act": {
|
|
290
|
+
"AnnexIV.1": {
|
|
291
|
+
"title": "General Description of the AI System",
|
|
292
|
+
"event_prefixes": ["llm.trace.", "llm.eval."],
|
|
293
|
+
"description": "Trace and evaluation telemetry documenting system purpose and behaviour.",
|
|
294
|
+
"min_event_count": 5,
|
|
295
|
+
"time_window_hours": None,
|
|
296
|
+
"remediation_steps": (
|
|
297
|
+
"Ensure `spanforge.configure()` is called at startup so `llm.trace.*` events are emitted. "
|
|
298
|
+
"Enable eval tracking: `spanforge.configure(track_eval=True)`. "
|
|
299
|
+
"Register your model with its intended purpose in the model registry."
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
"Art.13": {
|
|
303
|
+
"title": "Transparency — explainability of AI decisions",
|
|
304
|
+
"event_prefixes": ["explanation."],
|
|
305
|
+
"description": "Explainability records demonstrating that high-risk AI decisions are accompanied by human-readable rationale.",
|
|
306
|
+
"min_event_count": 5,
|
|
307
|
+
"time_window_hours": None,
|
|
308
|
+
"remediation_steps": (
|
|
309
|
+
"Integrate explainability for every high-risk AI decision: `sf_explain.explain(trace_id='...')`. "
|
|
310
|
+
"Verify explanation coverage with: `spanforge compliance report --framework eu_ai_act`."
|
|
311
|
+
),
|
|
312
|
+
},
|
|
313
|
+
"Art.14": {
|
|
314
|
+
"title": "Human Oversight — HITL review and escalation",
|
|
315
|
+
"event_prefixes": ["hitl.", "consent."],
|
|
316
|
+
"description": "Human-in-the-loop review, escalation, and consent events demonstrating mandatory human oversight of high-risk AI.",
|
|
317
|
+
"min_event_count": 5,
|
|
318
|
+
"time_window_hours": None,
|
|
319
|
+
"remediation_steps": (
|
|
320
|
+
"Add HITL review gates for high-risk decisions: `sf_hitl.review(trace_id='...')`. "
|
|
321
|
+
"Enforce data subject consent: `sf_consent.check(user_id='...')`."
|
|
322
|
+
),
|
|
323
|
+
},
|
|
324
|
+
"AnnexIV.5": {
|
|
325
|
+
"title": "Human Oversight Measures",
|
|
326
|
+
"event_prefixes": ["llm.guard.", "llm.audit.", "hitl."],
|
|
327
|
+
"description": "Guard, audit, and human-in-the-loop events demonstrating human oversight mechanisms.",
|
|
328
|
+
"min_event_count": 5,
|
|
329
|
+
"time_window_hours": None,
|
|
330
|
+
"remediation_steps": (
|
|
331
|
+
"Add gate guards to all high-risk AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`. "
|
|
332
|
+
"Emit audit records on gate decisions. "
|
|
333
|
+
"Add HITL escalation paths: `sf_hitl.review()`."
|
|
334
|
+
),
|
|
335
|
+
},
|
|
336
|
+
"AnnexIV.6": {
|
|
337
|
+
"title": "Robustness, Accuracy and Cybersecurity",
|
|
338
|
+
"event_prefixes": ["llm.eval.", "llm.drift."],
|
|
339
|
+
"description": "Evaluation and drift telemetry supporting robustness and accuracy evidence.",
|
|
340
|
+
"min_event_count": 5,
|
|
341
|
+
"time_window_hours": None,
|
|
342
|
+
"remediation_steps": (
|
|
343
|
+
"Enable eval tracking: `spanforge.configure(track_eval=True)`. "
|
|
344
|
+
"Enable drift detection: `spanforge.configure(drift_detection=True)`. "
|
|
345
|
+
"Run periodic evaluations and record results via `sf_eval`."
|
|
346
|
+
),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
"iso_42001": {
|
|
350
|
+
"6.1": {
|
|
351
|
+
"title": "Actions to Address AI Risks and Opportunities",
|
|
352
|
+
"event_prefixes": ["llm.drift.", "llm.guard.", "llm.eval."],
|
|
353
|
+
"description": "Drift, guard, and evaluation events supporting risk treatment records.",
|
|
354
|
+
"min_event_count": 5,
|
|
355
|
+
"time_window_hours": None,
|
|
356
|
+
"remediation_steps": (
|
|
357
|
+
"Enable drift detection and eval tracking: "
|
|
358
|
+
"`spanforge.configure(drift_detection=True, track_eval=True)`. "
|
|
359
|
+
"Add gate risk controls to AI pipelines: `sf_gate.configure(policy_file='policy.yaml')`."
|
|
360
|
+
),
|
|
361
|
+
},
|
|
362
|
+
"9.1": {
|
|
363
|
+
"title": "Monitoring, Measurement, Analysis and Evaluation",
|
|
364
|
+
"event_prefixes": ["llm.trace.", "llm.eval.", "llm.cost."],
|
|
365
|
+
"description": "Continuous telemetry supporting measurement and evaluation requirements.",
|
|
366
|
+
"min_event_count": 5,
|
|
367
|
+
"time_window_hours": None,
|
|
368
|
+
"remediation_steps": (
|
|
369
|
+
"Ensure continuous trace, eval, and cost events are emitted: "
|
|
370
|
+
"`spanforge.configure(track_eval=True, track_cost=True)`. "
|
|
371
|
+
"Configure a durable exporter so telemetry is not lost on process restart."
|
|
372
|
+
),
|
|
373
|
+
},
|
|
374
|
+
"10.1": {
|
|
375
|
+
"title": "Nonconformity and Corrective Action",
|
|
376
|
+
"event_prefixes": ["llm.audit.", "llm.guard."],
|
|
377
|
+
"description": "Audit and guard events documenting corrective actions.",
|
|
378
|
+
"min_event_count": 5,
|
|
379
|
+
"time_window_hours": None,
|
|
380
|
+
"remediation_steps": (
|
|
381
|
+
"Call `sf_audit.record()` for every corrective action taken on an AI nonconformity. "
|
|
382
|
+
"Configure gate alerts so guard trips automatically generate audit records."
|
|
383
|
+
),
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# Minimum event count to consider a clause "passed" (not just partial)
|
|
389
|
+
_MIN_PASS_THRESHOLD = 5
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Data classes
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ClauseStatus(enum.Enum):
|
|
398
|
+
"""Pass/fail/coverage status for a single compliance clause."""
|
|
399
|
+
|
|
400
|
+
PASS = "pass" # nosec B105
|
|
401
|
+
FAIL = "fail"
|
|
402
|
+
PARTIAL = "partial"
|
|
403
|
+
NOT_APPLICABLE = "not_applicable"
|
|
404
|
+
UNKNOWN = "unknown"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@dataclass
|
|
408
|
+
class EvidenceRecord:
|
|
409
|
+
"""Evidence collected for one framework clause."""
|
|
410
|
+
|
|
411
|
+
clause_id: str
|
|
412
|
+
status: ClauseStatus
|
|
413
|
+
evidence_count: int
|
|
414
|
+
audit_ids: list[str]
|
|
415
|
+
summary: str
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass
|
|
419
|
+
class ComplianceAttestation:
|
|
420
|
+
"""HMAC-signed attestation package for a model + framework + period."""
|
|
421
|
+
|
|
422
|
+
model_id: str
|
|
423
|
+
framework: str
|
|
424
|
+
period_from: str
|
|
425
|
+
period_to: str
|
|
426
|
+
generated_at: str
|
|
427
|
+
generated_by: str
|
|
428
|
+
clauses: list[EvidenceRecord]
|
|
429
|
+
overall_status: ClauseStatus
|
|
430
|
+
hmac_sig: str
|
|
431
|
+
model_owner: str | None = None
|
|
432
|
+
model_risk_tier: str | None = None
|
|
433
|
+
model_status: str | None = None
|
|
434
|
+
model_warnings: list[str] = field(default_factory=list)
|
|
435
|
+
explanation_coverage_pct: float | None = None
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def from_date(self) -> str:
|
|
439
|
+
"""Backward-compatible alias for ``period_from``."""
|
|
440
|
+
return self.period_from
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def to_date(self) -> str:
|
|
444
|
+
"""Backward-compatible alias for ``period_to``."""
|
|
445
|
+
return self.period_to
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def timestamp(self) -> str:
|
|
449
|
+
"""Backward-compatible alias for ``generated_at``."""
|
|
450
|
+
return self.generated_at
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def signature(self) -> str:
|
|
454
|
+
"""Backward-compatible alias for ``hmac_sig``."""
|
|
455
|
+
return self.hmac_sig
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def clauses_total(self) -> int:
|
|
459
|
+
"""Return the total number of evaluated clauses."""
|
|
460
|
+
return len(self.clauses)
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def clauses_covered(self) -> int:
|
|
464
|
+
"""Return the number of fully covered clauses."""
|
|
465
|
+
return sum(1 for clause in self.clauses if clause.status == ClauseStatus.PASS)
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def coverage_pct(self) -> float:
|
|
469
|
+
"""Return full-clause coverage as a percentage."""
|
|
470
|
+
if not self.clauses:
|
|
471
|
+
return 0.0
|
|
472
|
+
return round((self.clauses_covered / self.clauses_total) * 100, 1)
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def gaps(self) -> list[str]:
|
|
476
|
+
"""Return the clause IDs that still fail outright."""
|
|
477
|
+
return [clause.clause_id for clause in self.clauses if clause.status == ClauseStatus.FAIL]
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def total_events(self) -> int:
|
|
481
|
+
"""Return the total number of evidence events across all clauses."""
|
|
482
|
+
return sum(clause.evidence_count for clause in self.clauses)
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def attestation_id(self) -> str:
|
|
486
|
+
"""Return a stable identifier for the attestation payload."""
|
|
487
|
+
digest = hashlib.sha256(
|
|
488
|
+
f"{self.framework}|{self.model_id}|{self.period_from}|{self.period_to}|{self.generated_at}".encode()
|
|
489
|
+
).hexdigest()[:12]
|
|
490
|
+
return f"sfatt_{digest}"
|
|
491
|
+
|
|
492
|
+
def to_json(self) -> str:
|
|
493
|
+
"""Return the attestation as a compact JSON string."""
|
|
494
|
+
doc: dict[str, Any] = {
|
|
495
|
+
"model_id": self.model_id,
|
|
496
|
+
"framework": self.framework,
|
|
497
|
+
"period_from": self.period_from,
|
|
498
|
+
"period_to": self.period_to,
|
|
499
|
+
"generated_at": self.generated_at,
|
|
500
|
+
"generated_by": self.generated_by,
|
|
501
|
+
"overall_status": self.overall_status.value,
|
|
502
|
+
"hmac_sig": self.hmac_sig,
|
|
503
|
+
"clauses": [
|
|
504
|
+
{
|
|
505
|
+
"clause_id": r.clause_id,
|
|
506
|
+
"status": r.status.value,
|
|
507
|
+
"evidence_count": r.evidence_count,
|
|
508
|
+
"audit_ids": r.audit_ids[:20], # cap for readability
|
|
509
|
+
"summary": r.summary,
|
|
510
|
+
}
|
|
511
|
+
for r in self.clauses
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
if self.model_owner is not None:
|
|
515
|
+
doc["model_owner"] = self.model_owner
|
|
516
|
+
if self.model_risk_tier is not None:
|
|
517
|
+
doc["model_risk_tier"] = self.model_risk_tier
|
|
518
|
+
if self.model_status is not None:
|
|
519
|
+
doc["model_status"] = self.model_status
|
|
520
|
+
if self.model_warnings:
|
|
521
|
+
doc["model_warnings"] = self.model_warnings
|
|
522
|
+
if self.explanation_coverage_pct is not None:
|
|
523
|
+
doc["explanation_coverage_pct"] = self.explanation_coverage_pct
|
|
524
|
+
return json.dumps(doc, indent=2)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass
|
|
528
|
+
class GapReport:
|
|
529
|
+
"""Summary of compliance gaps found during the analysis."""
|
|
530
|
+
|
|
531
|
+
model_id: str
|
|
532
|
+
framework: str
|
|
533
|
+
period_from: str
|
|
534
|
+
period_to: str
|
|
535
|
+
generated_at: str
|
|
536
|
+
gap_clause_ids: list[str]
|
|
537
|
+
partial_clause_ids: list[str]
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def has_gaps(self) -> bool:
|
|
541
|
+
"""Return True if any gap clause IDs exist."""
|
|
542
|
+
return bool(self.gap_clause_ids)
|
|
543
|
+
|
|
544
|
+
@property
|
|
545
|
+
def total_issues(self) -> int:
|
|
546
|
+
"""Return total number of gap and partial clause issues."""
|
|
547
|
+
return len(self.gap_clause_ids) + len(self.partial_clause_ids)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass
|
|
551
|
+
class ComplianceEvidencePackage:
|
|
552
|
+
"""Full deliverable: attestation + human-readable report + gap analysis + audit exports."""
|
|
553
|
+
|
|
554
|
+
attestation: ComplianceAttestation
|
|
555
|
+
report_text: str
|
|
556
|
+
gap_report: GapReport
|
|
557
|
+
audit_exports: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def framework(self) -> str:
|
|
561
|
+
"""Return the framework for this package."""
|
|
562
|
+
return self.attestation.framework
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def model_id(self) -> str:
|
|
566
|
+
"""Return the model ID for this package."""
|
|
567
|
+
return self.attestation.model_id
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def mappings(self) -> list[EvidenceRecord]:
|
|
571
|
+
"""Backward-compatible alias for clause evidence records."""
|
|
572
|
+
return self.attestation.clauses
|
|
573
|
+
|
|
574
|
+
def to_json(self) -> str:
|
|
575
|
+
"""Return a signed JSON attestation with HMAC covering canonical bytes.
|
|
576
|
+
|
|
577
|
+
The output includes: framework, model_id, period, generated_at,
|
|
578
|
+
clauses with status/evidence_count/summary, gap_clause_ids,
|
|
579
|
+
overall_status, and hmac_sig.
|
|
580
|
+
"""
|
|
581
|
+
att = self.attestation
|
|
582
|
+
doc: dict[str, Any] = {
|
|
583
|
+
"framework": att.framework,
|
|
584
|
+
"model_id": att.model_id,
|
|
585
|
+
"period_from": att.period_from,
|
|
586
|
+
"period_to": att.period_to,
|
|
587
|
+
"generated_at": att.generated_at,
|
|
588
|
+
"generated_by": att.generated_by,
|
|
589
|
+
"overall_status": att.overall_status.value,
|
|
590
|
+
"clauses": [
|
|
591
|
+
{
|
|
592
|
+
"clause_id": r.clause_id,
|
|
593
|
+
"status": r.status.value,
|
|
594
|
+
"evidence_count": r.evidence_count,
|
|
595
|
+
"summary": r.summary,
|
|
596
|
+
}
|
|
597
|
+
for r in att.clauses
|
|
598
|
+
],
|
|
599
|
+
"gap_clause_ids": self.gap_report.gap_clause_ids,
|
|
600
|
+
"hmac_sig": att.hmac_sig,
|
|
601
|
+
}
|
|
602
|
+
if att.model_owner is not None:
|
|
603
|
+
doc["model_owner"] = att.model_owner
|
|
604
|
+
if att.model_risk_tier is not None:
|
|
605
|
+
doc["model_risk_tier"] = att.model_risk_tier
|
|
606
|
+
if att.model_status is not None:
|
|
607
|
+
doc["model_status"] = att.model_status
|
|
608
|
+
if att.model_warnings:
|
|
609
|
+
doc["model_warnings"] = att.model_warnings
|
|
610
|
+
if att.explanation_coverage_pct is not None:
|
|
611
|
+
doc["explanation_coverage_pct"] = att.explanation_coverage_pct
|
|
612
|
+
return json.dumps(doc, sort_keys=True, separators=(",", ":"))
|
|
613
|
+
|
|
614
|
+
def to_markdown(self) -> str:
|
|
615
|
+
"""Return the human-readable Markdown report.
|
|
616
|
+
|
|
617
|
+
This is the same content as ``report_text`` (already Markdown), exposed
|
|
618
|
+
as an explicit method for symmetry with ``to_json()`` and ``to_pdf()``.
|
|
619
|
+
"""
|
|
620
|
+
return self.report_text
|
|
621
|
+
|
|
622
|
+
def to_pdf(self, path: str | Any) -> Any:
|
|
623
|
+
"""Generate a signed PDF attestation report.
|
|
624
|
+
|
|
625
|
+
Requires ``reportlab``: ``pip install 'spanforge[compliance]'``.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
path: File path for the output PDF.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
:class:`pathlib.Path` to the written PDF file.
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
ImportError: If ``reportlab`` is not installed.
|
|
635
|
+
"""
|
|
636
|
+
from pathlib import Path as _Path
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
from reportlab.lib.pagesizes import A4
|
|
640
|
+
from reportlab.lib.units import mm
|
|
641
|
+
from reportlab.pdfgen import canvas
|
|
642
|
+
except ImportError:
|
|
643
|
+
raise ImportError(
|
|
644
|
+
"PDF attestation export requires reportlab. "
|
|
645
|
+
"Install it with: pip install 'spanforge[compliance]'"
|
|
646
|
+
) from None
|
|
647
|
+
|
|
648
|
+
out_path = _Path(path)
|
|
649
|
+
att = self.attestation
|
|
650
|
+
|
|
651
|
+
c = canvas.Canvas(str(out_path), pagesize=A4)
|
|
652
|
+
_, height = A4
|
|
653
|
+
y = height - 40 * mm
|
|
654
|
+
|
|
655
|
+
# Cover page
|
|
656
|
+
c.setFont("Helvetica-Bold", 18)
|
|
657
|
+
c.drawString(30 * mm, y, "SpanForge Compliance Attestation")
|
|
658
|
+
y -= 12 * mm
|
|
659
|
+
c.setFont("Helvetica", 11)
|
|
660
|
+
c.drawString(30 * mm, y, f"Framework: {att.framework.upper()}")
|
|
661
|
+
y -= 7 * mm
|
|
662
|
+
c.drawString(30 * mm, y, f"Model: {att.model_id}")
|
|
663
|
+
y -= 7 * mm
|
|
664
|
+
c.drawString(30 * mm, y, f"Period: {att.period_from} — {att.period_to}")
|
|
665
|
+
y -= 7 * mm
|
|
666
|
+
c.drawString(30 * mm, y, f"Generated: {att.generated_at}")
|
|
667
|
+
y -= 7 * mm
|
|
668
|
+
c.drawString(30 * mm, y, f"Overall Status: {att.overall_status.value.upper()}")
|
|
669
|
+
y -= 14 * mm
|
|
670
|
+
|
|
671
|
+
# Clause table
|
|
672
|
+
c.setFont("Helvetica-Bold", 12)
|
|
673
|
+
c.drawString(30 * mm, y, "Clause Analysis")
|
|
674
|
+
y -= 8 * mm
|
|
675
|
+
c.setFont("Helvetica", 9)
|
|
676
|
+
for rec in att.clauses:
|
|
677
|
+
if y < 30 * mm:
|
|
678
|
+
c.showPage()
|
|
679
|
+
y = height - 30 * mm
|
|
680
|
+
c.setFont("Helvetica", 9)
|
|
681
|
+
icon = {"pass": "PASS", "fail": "FAIL", "partial": "PARTIAL"}.get(rec.status.value, "?") # nosec B105
|
|
682
|
+
c.drawString(30 * mm, y, f"[{icon}] {rec.clause_id}: {rec.summary[:80]}")
|
|
683
|
+
y -= 5 * mm
|
|
684
|
+
|
|
685
|
+
# Gap list
|
|
686
|
+
if self.gap_report.has_gaps:
|
|
687
|
+
y -= 6 * mm
|
|
688
|
+
c.setFont("Helvetica-Bold", 11)
|
|
689
|
+
c.drawString(30 * mm, y, "Gaps Requiring Action")
|
|
690
|
+
y -= 6 * mm
|
|
691
|
+
c.setFont("Helvetica", 9)
|
|
692
|
+
for cid in self.gap_report.gap_clause_ids:
|
|
693
|
+
c.drawString(32 * mm, y, f"- {cid}")
|
|
694
|
+
y -= 5 * mm
|
|
695
|
+
|
|
696
|
+
# HMAC footer
|
|
697
|
+
y -= 10 * mm
|
|
698
|
+
c.setFont("Helvetica", 8)
|
|
699
|
+
c.drawString(30 * mm, y, f"HMAC-SHA256: {att.hmac_sig}")
|
|
700
|
+
|
|
701
|
+
c.save()
|
|
702
|
+
|
|
703
|
+
# Sign the PDF bytes and store in metadata
|
|
704
|
+
pdf_bytes = out_path.read_bytes()
|
|
705
|
+
pdf_hash = hashlib.sha256(pdf_bytes).hexdigest()
|
|
706
|
+
signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
707
|
+
if not signing_key or signing_key == "spanforge-default":
|
|
708
|
+
_log.warning(
|
|
709
|
+
"SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
|
|
710
|
+
"Set a strong secret before generating PDF attestations for production. "
|
|
711
|
+
"Example: export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)"
|
|
712
|
+
)
|
|
713
|
+
signing_key = _INSECURE_DEFAULT_KEY
|
|
714
|
+
pdf_hmac = _hmac.new(
|
|
715
|
+
signing_key.encode(),
|
|
716
|
+
pdf_hash.encode(),
|
|
717
|
+
hashlib.sha256,
|
|
718
|
+
).hexdigest()
|
|
719
|
+
|
|
720
|
+
# Re-open and set metadata
|
|
721
|
+
# Store the HMAC as a sidecar JSON (reportlab doesn't support PDF metadata update easily)
|
|
722
|
+
sidecar = out_path.with_suffix(".pdf.sig")
|
|
723
|
+
sidecar.write_text(
|
|
724
|
+
json.dumps({"SpanForgeHMAC": pdf_hmac, "pdf_sha256": pdf_hash}),
|
|
725
|
+
encoding="utf-8",
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
return out_path
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
# Engine
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class ComplianceMappingEngine:
|
|
737
|
+
"""Map spanforge telemetry events to framework clauses and generate evidence packages."""
|
|
738
|
+
|
|
739
|
+
def generate_evidence_package(
|
|
740
|
+
self,
|
|
741
|
+
model_id: str,
|
|
742
|
+
framework: str,
|
|
743
|
+
from_date: str,
|
|
744
|
+
to_date: str,
|
|
745
|
+
audit_events: list[dict[str, Any]] | None = None,
|
|
746
|
+
) -> ComplianceEvidencePackage:
|
|
747
|
+
"""Analyse *audit_events* and produce a full compliance evidence package.
|
|
748
|
+
|
|
749
|
+
Parameters
|
|
750
|
+
----------
|
|
751
|
+
model_id:
|
|
752
|
+
The AI model identifier (e.g. ``gpt-4o``).
|
|
753
|
+
framework:
|
|
754
|
+
One of ``soc2``, ``hipaa``, ``gdpr``, ``nist_ai_rmf``,
|
|
755
|
+
``eu_ai_act``, ``iso_42001``.
|
|
756
|
+
from_date / to_date:
|
|
757
|
+
ISO-8601 date strings defining the audit period.
|
|
758
|
+
audit_events:
|
|
759
|
+
Raw event dicts. If *None* or empty the engine will load from the
|
|
760
|
+
active ``TraceStore`` instead.
|
|
761
|
+
"""
|
|
762
|
+
# ------------------------------------------------------------------
|
|
763
|
+
# Resolve framework
|
|
764
|
+
# ------------------------------------------------------------------
|
|
765
|
+
if isinstance(framework, ComplianceFramework):
|
|
766
|
+
raw = framework.value.lower()
|
|
767
|
+
else:
|
|
768
|
+
raw = str(framework).lower().replace("-", "_").replace(" ", "_")
|
|
769
|
+
|
|
770
|
+
framework_key = _FRAMEWORK_KEY_MAP.get(raw, raw)
|
|
771
|
+
if framework_key not in _FRAMEWORK_CLAUSES:
|
|
772
|
+
supported = ", ".join(sorted(_FRAMEWORK_CLAUSES))
|
|
773
|
+
raise ValueError(f"Unknown framework {framework!r}. Supported: {supported}")
|
|
774
|
+
|
|
775
|
+
# ------------------------------------------------------------------
|
|
776
|
+
# Load events
|
|
777
|
+
# ------------------------------------------------------------------
|
|
778
|
+
if not audit_events:
|
|
779
|
+
audit_events = self._load_from_store()
|
|
780
|
+
|
|
781
|
+
# ------------------------------------------------------------------
|
|
782
|
+
# Filter to period
|
|
783
|
+
# ------------------------------------------------------------------
|
|
784
|
+
period_events = self._filter_period(audit_events, from_date, to_date)
|
|
785
|
+
|
|
786
|
+
# ------------------------------------------------------------------
|
|
787
|
+
# Map events to clauses
|
|
788
|
+
# ------------------------------------------------------------------
|
|
789
|
+
clauses_def = _FRAMEWORK_CLAUSES[framework_key]
|
|
790
|
+
evidence_records: list[EvidenceRecord] = []
|
|
791
|
+
audit_exports: dict[str, list[dict[str, Any]]] = {}
|
|
792
|
+
|
|
793
|
+
_now_utc = datetime.now(timezone.utc)
|
|
794
|
+
|
|
795
|
+
for clause_id, clause_info in clauses_def.items():
|
|
796
|
+
prefixes: list[str] = clause_info["event_prefixes"]
|
|
797
|
+
# Per-clause minimum, falling back to global default
|
|
798
|
+
clause_min: int = clause_info.get("min_event_count") or _MIN_PASS_THRESHOLD
|
|
799
|
+
# Optional rolling time window (hours) scoped to this clause
|
|
800
|
+
tw_hours: int | None = clause_info.get("time_window_hours")
|
|
801
|
+
|
|
802
|
+
matching = [
|
|
803
|
+
e
|
|
804
|
+
for e in period_events
|
|
805
|
+
if any(str(e.get("event_type", "")).startswith(p) for p in prefixes)
|
|
806
|
+
]
|
|
807
|
+
|
|
808
|
+
# Apply clause-level time window filter when defined
|
|
809
|
+
if tw_hours is not None:
|
|
810
|
+
_tw_cutoff = _now_utc - timedelta(hours=tw_hours)
|
|
811
|
+
_cutoff_iso = _tw_cutoff.isoformat()
|
|
812
|
+
matching = [e for e in matching if str(e.get("timestamp", "")) >= _cutoff_iso]
|
|
813
|
+
|
|
814
|
+
model_matching = [e for e in matching if self._event_matches_model(e, model_id)]
|
|
815
|
+
# When a model_id is given, restrict to model-specific events only.
|
|
816
|
+
# Fall back to all matching events only when no model_id is specified.
|
|
817
|
+
effective = model_matching if model_id else matching
|
|
818
|
+
|
|
819
|
+
audit_ids = [str(e.get("event_id", "")) for e in effective[:50]]
|
|
820
|
+
count = len(effective)
|
|
821
|
+
audit_exports[clause_id] = [
|
|
822
|
+
{k: v for k, v in e.items() if k != "signature"} for e in effective[:100]
|
|
823
|
+
]
|
|
824
|
+
|
|
825
|
+
if count >= clause_min:
|
|
826
|
+
status = ClauseStatus.PASS
|
|
827
|
+
summary = f"{count} events from prefixes {prefixes} satisfy this clause."
|
|
828
|
+
elif count > 0:
|
|
829
|
+
status = ClauseStatus.PARTIAL
|
|
830
|
+
summary = (
|
|
831
|
+
f"Only {count} events found (need ≥{clause_min}). "
|
|
832
|
+
f"Increase instrumentation coverage."
|
|
833
|
+
)
|
|
834
|
+
else:
|
|
835
|
+
status = ClauseStatus.FAIL
|
|
836
|
+
summary = (
|
|
837
|
+
f"No events found matching {prefixes}. "
|
|
838
|
+
f"Add {framework_key.upper()} instrumentation for this clause."
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
evidence_records.append(
|
|
842
|
+
EvidenceRecord(
|
|
843
|
+
clause_id=clause_id,
|
|
844
|
+
status=status,
|
|
845
|
+
evidence_count=count,
|
|
846
|
+
audit_ids=audit_ids,
|
|
847
|
+
summary=summary,
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# ------------------------------------------------------------------
|
|
852
|
+
# Overall status
|
|
853
|
+
# ------------------------------------------------------------------
|
|
854
|
+
statuses = [r.status for r in evidence_records]
|
|
855
|
+
if all(s == ClauseStatus.PASS for s in statuses):
|
|
856
|
+
overall = ClauseStatus.PASS
|
|
857
|
+
elif any(s == ClauseStatus.FAIL for s in statuses):
|
|
858
|
+
overall = ClauseStatus.FAIL
|
|
859
|
+
else:
|
|
860
|
+
overall = ClauseStatus.PARTIAL
|
|
861
|
+
|
|
862
|
+
# ------------------------------------------------------------------
|
|
863
|
+
# HMAC signature
|
|
864
|
+
# ------------------------------------------------------------------
|
|
865
|
+
generated_at = datetime.now(timezone.utc).isoformat()
|
|
866
|
+
sig_payload = json.dumps(
|
|
867
|
+
{
|
|
868
|
+
"model_id": model_id,
|
|
869
|
+
"framework": framework_key,
|
|
870
|
+
"from": from_date,
|
|
871
|
+
"to": to_date,
|
|
872
|
+
"generated_at": generated_at,
|
|
873
|
+
"clauses": {r.clause_id: r.status.value for r in evidence_records},
|
|
874
|
+
"event_count": len(period_events),
|
|
875
|
+
},
|
|
876
|
+
sort_keys=True,
|
|
877
|
+
)
|
|
878
|
+
signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
879
|
+
if not signing_key or signing_key == "spanforge-default":
|
|
880
|
+
_log.warning(
|
|
881
|
+
"SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
|
|
882
|
+
"Set a strong secret before generating compliance attestations for production. "
|
|
883
|
+
"Example: export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)"
|
|
884
|
+
)
|
|
885
|
+
signing_key = _INSECURE_DEFAULT_KEY
|
|
886
|
+
hmac_sig = _hmac.new(
|
|
887
|
+
signing_key.encode(),
|
|
888
|
+
sig_payload.encode(),
|
|
889
|
+
hashlib.sha256,
|
|
890
|
+
).hexdigest()
|
|
891
|
+
|
|
892
|
+
attestation = ComplianceAttestation(
|
|
893
|
+
model_id=model_id,
|
|
894
|
+
framework=framework_key,
|
|
895
|
+
period_from=from_date,
|
|
896
|
+
period_to=to_date,
|
|
897
|
+
generated_at=generated_at,
|
|
898
|
+
generated_by="spanforge.core.compliance_mapping v1",
|
|
899
|
+
clauses=evidence_records,
|
|
900
|
+
overall_status=overall,
|
|
901
|
+
hmac_sig=hmac_sig,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
# ------------------------------------------------------------------
|
|
905
|
+
# Model registry enrichment (Fix 3)
|
|
906
|
+
# ------------------------------------------------------------------
|
|
907
|
+
self._enrich_from_model_registry(attestation, model_id)
|
|
908
|
+
|
|
909
|
+
# ------------------------------------------------------------------
|
|
910
|
+
# Explanation coverage metric (Fix 4)
|
|
911
|
+
# ------------------------------------------------------------------
|
|
912
|
+
self._compute_explanation_coverage(attestation, period_events, model_id)
|
|
913
|
+
|
|
914
|
+
# ------------------------------------------------------------------
|
|
915
|
+
# Gap report
|
|
916
|
+
# ------------------------------------------------------------------
|
|
917
|
+
gap_ids = [r.clause_id for r in evidence_records if r.status == ClauseStatus.FAIL]
|
|
918
|
+
partial_ids = [r.clause_id for r in evidence_records if r.status == ClauseStatus.PARTIAL]
|
|
919
|
+
gap_report = GapReport(
|
|
920
|
+
model_id=model_id,
|
|
921
|
+
framework=framework_key,
|
|
922
|
+
period_from=from_date,
|
|
923
|
+
period_to=to_date,
|
|
924
|
+
generated_at=generated_at,
|
|
925
|
+
gap_clause_ids=gap_ids,
|
|
926
|
+
partial_clause_ids=partial_ids,
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# ------------------------------------------------------------------
|
|
930
|
+
# Human-readable report
|
|
931
|
+
# ------------------------------------------------------------------
|
|
932
|
+
report_text = self._build_report(attestation, gap_report, period_events, clauses_def)
|
|
933
|
+
|
|
934
|
+
return ComplianceEvidencePackage(
|
|
935
|
+
attestation=attestation,
|
|
936
|
+
report_text=report_text,
|
|
937
|
+
gap_report=gap_report,
|
|
938
|
+
audit_exports=audit_exports,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# Internal helpers
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
@staticmethod
|
|
946
|
+
def _enrich_from_model_registry(attestation: ComplianceAttestation, model_id: str) -> None:
|
|
947
|
+
"""Enrich *attestation* with model registry metadata (owner, risk_tier, status)."""
|
|
948
|
+
if not model_id:
|
|
949
|
+
return
|
|
950
|
+
try:
|
|
951
|
+
from spanforge.model_registry import get_model
|
|
952
|
+
|
|
953
|
+
entry = get_model(model_id)
|
|
954
|
+
if entry is None:
|
|
955
|
+
attestation.model_warnings.append(
|
|
956
|
+
f"Model {model_id!r} is not registered in the model registry. "
|
|
957
|
+
"Register it for full compliance traceability."
|
|
958
|
+
)
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
attestation.model_owner = entry.owner
|
|
962
|
+
attestation.model_risk_tier = entry.risk_tier
|
|
963
|
+
attestation.model_status = entry.status
|
|
964
|
+
|
|
965
|
+
if entry.status == "deprecated":
|
|
966
|
+
attestation.model_warnings.append(
|
|
967
|
+
f"Model {model_id!r} is DEPRECATED in the registry. "
|
|
968
|
+
"Consider migrating to an active model before the next audit period."
|
|
969
|
+
)
|
|
970
|
+
elif entry.status == "retired":
|
|
971
|
+
attestation.model_warnings.append(
|
|
972
|
+
f"Model {model_id!r} is RETIRED in the registry. "
|
|
973
|
+
"Generating a compliance attestation for a retired model is unusual — "
|
|
974
|
+
"verify this is intentional."
|
|
975
|
+
)
|
|
976
|
+
except Exception as _err:
|
|
977
|
+
_log.debug("model registry lookup failed: %s", _err)
|
|
978
|
+
|
|
979
|
+
def _compute_explanation_coverage(
|
|
980
|
+
self,
|
|
981
|
+
attestation: ComplianceAttestation,
|
|
982
|
+
period_events: list[dict[str, Any]],
|
|
983
|
+
model_id: str,
|
|
984
|
+
) -> None:
|
|
985
|
+
"""Compute explanation coverage: % of high-risk decisions with an explanation."""
|
|
986
|
+
# Count decisions (trace spans) for this model in the period
|
|
987
|
+
decision_events = [
|
|
988
|
+
e
|
|
989
|
+
for e in period_events
|
|
990
|
+
if str(e.get("event_type", "")).startswith(("llm.trace.", "hitl."))
|
|
991
|
+
and (
|
|
992
|
+
not model_id
|
|
993
|
+
or (e.get("payload") or {}).get("model", {}).get("name", "").lower()
|
|
994
|
+
== model_id.lower()
|
|
995
|
+
or (e.get("payload") or {}).get("model_id", "").lower() == model_id.lower()
|
|
996
|
+
or str((e.get("payload") or {}).get("model", "")).lower() == model_id.lower()
|
|
997
|
+
)
|
|
998
|
+
]
|
|
999
|
+
explanation_events = [
|
|
1000
|
+
e for e in period_events if str(e.get("event_type", "")).startswith("explanation.")
|
|
1001
|
+
]
|
|
1002
|
+
|
|
1003
|
+
decision_count = len(decision_events)
|
|
1004
|
+
explanation_count = len(explanation_events)
|
|
1005
|
+
|
|
1006
|
+
if decision_count > 0:
|
|
1007
|
+
attestation.explanation_coverage_pct = round(
|
|
1008
|
+
min(explanation_count / decision_count * 100, 100.0), 1
|
|
1009
|
+
)
|
|
1010
|
+
else:
|
|
1011
|
+
# No decisions → coverage is N/A; store None to omit from output
|
|
1012
|
+
attestation.explanation_coverage_pct = None
|
|
1013
|
+
|
|
1014
|
+
def _load_from_store(self) -> list[dict[str, Any]]:
|
|
1015
|
+
"""Load events from the active TraceStore."""
|
|
1016
|
+
try:
|
|
1017
|
+
from spanforge._store import get_store
|
|
1018
|
+
|
|
1019
|
+
store = get_store()
|
|
1020
|
+
with store._lock:
|
|
1021
|
+
events = [e for evts in store._traces.values() for e in evts]
|
|
1022
|
+
return [
|
|
1023
|
+
{
|
|
1024
|
+
"event_id": getattr(e, "event_id", None),
|
|
1025
|
+
"event_type": getattr(e, "event_type", None),
|
|
1026
|
+
"timestamp": getattr(e, "timestamp", None),
|
|
1027
|
+
"source": getattr(e, "source", None),
|
|
1028
|
+
"trace_id": getattr(e, "trace_id", None),
|
|
1029
|
+
"span_id": getattr(e, "span_id", None),
|
|
1030
|
+
"payload": getattr(e, "payload", {}),
|
|
1031
|
+
"tags": getattr(e, "tags", {}),
|
|
1032
|
+
"signature": getattr(e, "signature", None),
|
|
1033
|
+
}
|
|
1034
|
+
for e in events
|
|
1035
|
+
]
|
|
1036
|
+
except Exception:
|
|
1037
|
+
return []
|
|
1038
|
+
|
|
1039
|
+
@staticmethod
|
|
1040
|
+
def _filter_period(
|
|
1041
|
+
events: list[dict[str, Any]], from_date: str, to_date: str
|
|
1042
|
+
) -> list[dict[str, Any]]:
|
|
1043
|
+
"""Filter events to the requested date range (inclusive)."""
|
|
1044
|
+
|
|
1045
|
+
def _parse(s: str) -> datetime | None:
|
|
1046
|
+
"""Parse a date or datetime string to an aware UTC datetime."""
|
|
1047
|
+
try:
|
|
1048
|
+
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
1049
|
+
except ValueError:
|
|
1050
|
+
return None
|
|
1051
|
+
if dt.tzinfo is None:
|
|
1052
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
1053
|
+
return dt
|
|
1054
|
+
|
|
1055
|
+
from_dt = _parse(from_date)
|
|
1056
|
+
to_dt = _parse(to_date)
|
|
1057
|
+
if from_dt is None or to_dt is None:
|
|
1058
|
+
raise ValueError(
|
|
1059
|
+
f"Cannot parse date range: from_date={from_date!r}, to_date={to_date!r}. "
|
|
1060
|
+
"Use ISO-8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ"
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
filtered = []
|
|
1064
|
+
for e in events:
|
|
1065
|
+
ts_raw = e.get("timestamp")
|
|
1066
|
+
if not ts_raw:
|
|
1067
|
+
continue
|
|
1068
|
+
ts = _parse(str(ts_raw))
|
|
1069
|
+
if ts is not None and from_dt <= ts <= to_dt:
|
|
1070
|
+
filtered.append(e)
|
|
1071
|
+
|
|
1072
|
+
return filtered # empty list is correct when no events fall in the period
|
|
1073
|
+
|
|
1074
|
+
@staticmethod
|
|
1075
|
+
def _event_matches_model(event: dict[str, Any], model_id: str) -> bool:
|
|
1076
|
+
"""Return True if event is associated with *model_id*."""
|
|
1077
|
+
if not model_id:
|
|
1078
|
+
return True
|
|
1079
|
+
payload = event.get("payload") or {}
|
|
1080
|
+
model_block = payload.get("model") or {}
|
|
1081
|
+
model_name = (
|
|
1082
|
+
model_block.get("name") or payload.get("model_id") or payload.get("model") or ""
|
|
1083
|
+
)
|
|
1084
|
+
return model_id.lower() == str(model_name).lower()
|
|
1085
|
+
|
|
1086
|
+
@staticmethod
|
|
1087
|
+
def _build_report(
|
|
1088
|
+
att: ComplianceAttestation,
|
|
1089
|
+
gap: GapReport,
|
|
1090
|
+
events: list[dict[str, Any]],
|
|
1091
|
+
clauses_def: dict[str, Any],
|
|
1092
|
+
) -> str:
|
|
1093
|
+
"""Build a human-readable markdown-style compliance report."""
|
|
1094
|
+
lines = [
|
|
1095
|
+
"# spanforge Compliance Report",
|
|
1096
|
+
"",
|
|
1097
|
+
"| Field | Value |",
|
|
1098
|
+
"|---------------|-------|",
|
|
1099
|
+
f"| Framework | {att.framework.upper()} |",
|
|
1100
|
+
f"| Model | {att.model_id} |",
|
|
1101
|
+
f"| Period | {att.period_from} → {att.period_to} |",
|
|
1102
|
+
f"| Generated | {att.generated_at} |",
|
|
1103
|
+
f"| Overall | **{att.overall_status.value.upper()}** |",
|
|
1104
|
+
f"| Events in scope | {len(events)} |",
|
|
1105
|
+
f"| HMAC Sig | `{att.hmac_sig[:32]}…` |",
|
|
1106
|
+
]
|
|
1107
|
+
|
|
1108
|
+
# Model registry metadata
|
|
1109
|
+
if att.model_owner is not None:
|
|
1110
|
+
lines.append(f"| Model Owner | {att.model_owner} |")
|
|
1111
|
+
if att.model_risk_tier is not None:
|
|
1112
|
+
lines.append(f"| Risk Tier | {att.model_risk_tier} |")
|
|
1113
|
+
if att.model_status is not None:
|
|
1114
|
+
lines.append(f"| Model Status | {att.model_status} |")
|
|
1115
|
+
|
|
1116
|
+
# Explanation coverage
|
|
1117
|
+
if att.explanation_coverage_pct is not None:
|
|
1118
|
+
lines.append(f"| Explanation Coverage | {att.explanation_coverage_pct}% |")
|
|
1119
|
+
|
|
1120
|
+
lines.append("")
|
|
1121
|
+
|
|
1122
|
+
# Model warnings
|
|
1123
|
+
if att.model_warnings:
|
|
1124
|
+
lines.append("## ⚠️ Model Registry Warnings")
|
|
1125
|
+
lines.append("")
|
|
1126
|
+
for w in att.model_warnings:
|
|
1127
|
+
lines.append(f"- {w}")
|
|
1128
|
+
lines.append("")
|
|
1129
|
+
|
|
1130
|
+
lines.extend(
|
|
1131
|
+
[
|
|
1132
|
+
"## Clause Analysis",
|
|
1133
|
+
"",
|
|
1134
|
+
]
|
|
1135
|
+
)
|
|
1136
|
+
for rec in att.clauses:
|
|
1137
|
+
info = clauses_def.get(rec.clause_id, {})
|
|
1138
|
+
icon = {"pass": "✅", "fail": "❌", "partial": "⚠️"}.get(rec.status.value, "❓") # nosec B105
|
|
1139
|
+
lines.append(f"### {icon} {rec.clause_id} — {info.get('title', rec.clause_id)}")
|
|
1140
|
+
lines.append("")
|
|
1141
|
+
lines.append(f"- **Status**: {rec.status.value.upper()}")
|
|
1142
|
+
lines.append(f"- **Evidence events**: {rec.evidence_count}")
|
|
1143
|
+
lines.append(f"- **Summary**: {rec.summary}")
|
|
1144
|
+
lines.append("")
|
|
1145
|
+
|
|
1146
|
+
if gap.has_gaps:
|
|
1147
|
+
lines.append("## ❌ Gaps Requiring Action")
|
|
1148
|
+
lines.append("")
|
|
1149
|
+
for cid in gap.gap_clause_ids:
|
|
1150
|
+
info = clauses_def.get(cid, {})
|
|
1151
|
+
lines.append(
|
|
1152
|
+
f"- **{cid}** — {info.get('title', cid)}: {info.get('description', '')}"
|
|
1153
|
+
)
|
|
1154
|
+
remediation = info.get("remediation_steps")
|
|
1155
|
+
if remediation:
|
|
1156
|
+
lines.append(f" > **Fix**: {remediation}")
|
|
1157
|
+
lines.append("")
|
|
1158
|
+
|
|
1159
|
+
if gap.partial_clause_ids:
|
|
1160
|
+
lines.append("## ⚠️ Partial Coverage")
|
|
1161
|
+
lines.append("")
|
|
1162
|
+
for cid in gap.partial_clause_ids:
|
|
1163
|
+
info = clauses_def.get(cid, {})
|
|
1164
|
+
lines.append(f"- **{cid}** — {info.get('title', cid)}")
|
|
1165
|
+
lines.append("")
|
|
1166
|
+
|
|
1167
|
+
lines.append("---")
|
|
1168
|
+
lines.append(
|
|
1169
|
+
"*Generated by spanforge.core.compliance_mapping. HMAC key: `SPANFORGE_SIGNING_KEY` env var.*"
|
|
1170
|
+
)
|
|
1171
|
+
return "\n".join(lines)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
# ---------------------------------------------------------------------------
|
|
1175
|
+
# Attestation verification
|
|
1176
|
+
# ---------------------------------------------------------------------------
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def verify_attestation_signature(attestation: ComplianceAttestation) -> bool:
|
|
1180
|
+
"""Re-compute the HMAC and compare against the stored signature.
|
|
1181
|
+
|
|
1182
|
+
Returns ``True`` if the attestation has not been tampered with.
|
|
1183
|
+
Note: verification requires the same ``SPANFORGE_SIGNING_KEY`` that was
|
|
1184
|
+
used during generation.
|
|
1185
|
+
"""
|
|
1186
|
+
sig_payload = json.dumps(
|
|
1187
|
+
{
|
|
1188
|
+
"model_id": attestation.model_id,
|
|
1189
|
+
"framework": attestation.framework,
|
|
1190
|
+
"from": attestation.period_from,
|
|
1191
|
+
"to": attestation.period_to,
|
|
1192
|
+
"generated_at": attestation.generated_at,
|
|
1193
|
+
"clauses": {r.clause_id: r.status.value for r in attestation.clauses},
|
|
1194
|
+
"event_count": sum(r.evidence_count for r in attestation.clauses),
|
|
1195
|
+
},
|
|
1196
|
+
sort_keys=True,
|
|
1197
|
+
)
|
|
1198
|
+
signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
1199
|
+
if not signing_key or signing_key == "spanforge-default":
|
|
1200
|
+
_log.warning(
|
|
1201
|
+
"SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
|
|
1202
|
+
"Attestation verification requires the same key used at signing time. "
|
|
1203
|
+
"Example: export SPANFORGE_SIGNING_KEY=<your-secret>"
|
|
1204
|
+
)
|
|
1205
|
+
signing_key = _INSECURE_DEFAULT_KEY
|
|
1206
|
+
expected = _hmac.new(
|
|
1207
|
+
signing_key.encode(),
|
|
1208
|
+
sig_payload.encode(),
|
|
1209
|
+
hashlib.sha256,
|
|
1210
|
+
).hexdigest()
|
|
1211
|
+
return _hmac.compare_digest(attestation.hmac_sig, expected)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def verify_pdf_attestation(path: str | Any, org_secret: str | None = None) -> bool:
|
|
1215
|
+
"""Verify that a PDF attestation has not been modified since signing.
|
|
1216
|
+
|
|
1217
|
+
Reads the ``.pdf.sig`` sidecar file to obtain the original HMAC, then
|
|
1218
|
+
re-computes ``HMAC-SHA256(SHA256(pdf_bytes), org_secret)`` and compares.
|
|
1219
|
+
|
|
1220
|
+
Args:
|
|
1221
|
+
path: Path to the PDF file.
|
|
1222
|
+
org_secret: HMAC signing key. If ``None``, reads from
|
|
1223
|
+
``SPANFORGE_SIGNING_KEY`` env var.
|
|
1224
|
+
|
|
1225
|
+
Returns:
|
|
1226
|
+
``True`` if the PDF bytes have not been altered since signing.
|
|
1227
|
+
"""
|
|
1228
|
+
from pathlib import Path as _Path
|
|
1229
|
+
|
|
1230
|
+
pdf_path = _Path(path)
|
|
1231
|
+
sig_path = pdf_path.with_suffix(".pdf.sig")
|
|
1232
|
+
|
|
1233
|
+
if not sig_path.exists():
|
|
1234
|
+
return False
|
|
1235
|
+
|
|
1236
|
+
sig_data = json.loads(sig_path.read_text(encoding="utf-8"))
|
|
1237
|
+
stored_hmac = sig_data.get("SpanForgeHMAC", "")
|
|
1238
|
+
|
|
1239
|
+
signing_key = org_secret or os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
1240
|
+
if not signing_key or signing_key == "spanforge-default":
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
"SPANFORGE_SIGNING_KEY is not set or uses the insecure default value. "
|
|
1243
|
+
"PDF attestation verification requires the same key used at signing time. "
|
|
1244
|
+
"Example: export SPANFORGE_SIGNING_KEY=<your-secret>"
|
|
1245
|
+
)
|
|
1246
|
+
pdf_bytes = pdf_path.read_bytes()
|
|
1247
|
+
pdf_hash = hashlib.sha256(pdf_bytes).hexdigest()
|
|
1248
|
+
expected_hmac = _hmac.new(
|
|
1249
|
+
signing_key.encode(),
|
|
1250
|
+
pdf_hash.encode(),
|
|
1251
|
+
hashlib.sha256,
|
|
1252
|
+
).hexdigest()
|
|
1253
|
+
|
|
1254
|
+
return _hmac.compare_digest(stored_hmac, expected_hmac)
|