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,711 @@
|
|
|
1
|
+
"""Compliance command group for the SpanForge CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
_FRAMEWORK_SLUG_MAP: dict[str, str] = {
|
|
13
|
+
"eu_ai_act": "EU AI Act",
|
|
14
|
+
"iso_42001": "ISO/IEC 42001",
|
|
15
|
+
"nist_ai_rmf": "NIST AI RMF",
|
|
16
|
+
"gdpr": "GDPR",
|
|
17
|
+
"soc2": "SOC 2 Type II",
|
|
18
|
+
"hipaa": "HIPAA",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_framework(framework: str) -> tuple[str, Any]:
|
|
23
|
+
"""Resolve a CLI framework value to a ComplianceFramework enum."""
|
|
24
|
+
from spanforge.core.compliance_mapping import ComplianceFramework
|
|
25
|
+
|
|
26
|
+
fw_map = {member.value: member for member in ComplianceFramework}
|
|
27
|
+
for slug, value in _FRAMEWORK_SLUG_MAP.items():
|
|
28
|
+
if value in fw_map:
|
|
29
|
+
fw_map[slug] = fw_map[value]
|
|
30
|
+
|
|
31
|
+
framework_key = framework.lower()
|
|
32
|
+
for key, fw_member in fw_map.items():
|
|
33
|
+
if key.lower() == framework_key:
|
|
34
|
+
return framework_key, fw_member
|
|
35
|
+
|
|
36
|
+
valid = ", ".join(sorted(_FRAMEWORK_SLUG_MAP))
|
|
37
|
+
print(f"error: unknown framework {framework!r}. Valid slugs: {valid}", file=sys.stderr)
|
|
38
|
+
return framework_key, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_audit_events(events_file: str | None) -> tuple[int, list[dict[str, Any]] | None]:
|
|
42
|
+
"""Load audit events from an optional JSONL file."""
|
|
43
|
+
if not events_file:
|
|
44
|
+
return 0, None
|
|
45
|
+
|
|
46
|
+
events_path = Path(events_file)
|
|
47
|
+
if not events_path.exists():
|
|
48
|
+
print(f"error: events file not found: {events_path}", file=sys.stderr)
|
|
49
|
+
return 2, None
|
|
50
|
+
|
|
51
|
+
audit_events: list[dict[str, Any]] = []
|
|
52
|
+
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
audit_events.append(json.loads(line))
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
print(f"warning: skipping invalid JSON line in events file: {exc}", file=sys.stderr)
|
|
60
|
+
return 0, audit_events
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _attestation_from_dict(data: dict[str, Any]) -> object:
|
|
64
|
+
"""Reconstruct a ComplianceAttestation from its JSON form."""
|
|
65
|
+
from spanforge.core.compliance_mapping import (
|
|
66
|
+
ClauseStatus,
|
|
67
|
+
ComplianceAttestation,
|
|
68
|
+
EvidenceRecord,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
period = data.get("period", {})
|
|
72
|
+
clauses = [
|
|
73
|
+
EvidenceRecord(
|
|
74
|
+
clause_id=clause["clause_id"],
|
|
75
|
+
status=ClauseStatus(clause["status"]),
|
|
76
|
+
evidence_count=clause.get("evidence_count", 0),
|
|
77
|
+
audit_ids=clause.get("audit_ids", []),
|
|
78
|
+
summary=clause.get("summary", ""),
|
|
79
|
+
)
|
|
80
|
+
for clause in data.get("clauses", [])
|
|
81
|
+
]
|
|
82
|
+
return ComplianceAttestation(
|
|
83
|
+
model_id=data["model_id"],
|
|
84
|
+
framework=data["framework"],
|
|
85
|
+
period_from=period.get("from", data.get("period_from", "")),
|
|
86
|
+
period_to=period.get("to", data.get("period_to", "")),
|
|
87
|
+
generated_at=data.get("generated_at", ""),
|
|
88
|
+
generated_by=data.get("generated_by", ""),
|
|
89
|
+
clauses=clauses,
|
|
90
|
+
overall_status=ClauseStatus(data["overall_status"]),
|
|
91
|
+
hmac_sig=data.get("hmac_sig", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
96
|
+
"""Implement ``spanforge compliance generate``."""
|
|
97
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine
|
|
98
|
+
|
|
99
|
+
framework_key, framework = _resolve_framework(args.framework)
|
|
100
|
+
if framework is None:
|
|
101
|
+
return 2
|
|
102
|
+
|
|
103
|
+
status, audit_events = _load_audit_events(getattr(args, "events_file", None))
|
|
104
|
+
if status != 0:
|
|
105
|
+
return status
|
|
106
|
+
|
|
107
|
+
engine = ComplianceMappingEngine()
|
|
108
|
+
try:
|
|
109
|
+
package = engine.generate_evidence_package(
|
|
110
|
+
model_id=args.model_id,
|
|
111
|
+
framework=framework.value,
|
|
112
|
+
from_date=args.from_date,
|
|
113
|
+
to_date=args.to_date,
|
|
114
|
+
audit_events=audit_events or None,
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
print(f"error: evidence package generation failed: {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
out_dir = Path(args.output)
|
|
121
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
safe_id = args.model_id.replace("/", "_")[:40]
|
|
124
|
+
prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
|
|
125
|
+
|
|
126
|
+
attestation_path = out_dir / f"{prefix}_attestation.json"
|
|
127
|
+
attestation_path.write_text(package.attestation.to_json(), encoding="utf-8")
|
|
128
|
+
print(f"[✓] Attestation → {attestation_path}")
|
|
129
|
+
|
|
130
|
+
report_path = out_dir / f"{prefix}_report.txt"
|
|
131
|
+
report_path.write_text(package.report_text, encoding="utf-8")
|
|
132
|
+
print(f"[✓] Report → {report_path}")
|
|
133
|
+
|
|
134
|
+
if package.gap_report.has_gaps:
|
|
135
|
+
from spanforge.core.compliance_mapping import _FRAMEWORK_CLAUSES, _FRAMEWORK_KEY_MAP
|
|
136
|
+
|
|
137
|
+
_fw_key = _FRAMEWORK_KEY_MAP.get(framework_key, framework_key)
|
|
138
|
+
_clauses_def = _FRAMEWORK_CLAUSES.get(_fw_key, {})
|
|
139
|
+
gap_data = {
|
|
140
|
+
"model_id": package.gap_report.model_id,
|
|
141
|
+
"framework": package.gap_report.framework,
|
|
142
|
+
"period_from": package.gap_report.period_from,
|
|
143
|
+
"period_to": package.gap_report.period_to,
|
|
144
|
+
"generated_at": package.gap_report.generated_at,
|
|
145
|
+
"gap_clause_ids": package.gap_report.gap_clause_ids,
|
|
146
|
+
"partial_clause_ids": package.gap_report.partial_clause_ids,
|
|
147
|
+
"remediation": {
|
|
148
|
+
cid: _clauses_def.get(cid, {}).get("remediation_steps", "")
|
|
149
|
+
for cid in package.gap_report.gap_clause_ids
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
gap_path = out_dir / f"{prefix}_gap_report.json"
|
|
153
|
+
gap_path.write_text(json.dumps(gap_data, indent=2), encoding="utf-8")
|
|
154
|
+
print(f"[✓] Gap report → {gap_path}")
|
|
155
|
+
else:
|
|
156
|
+
print("[✓] No compliance gaps found")
|
|
157
|
+
|
|
158
|
+
if package.audit_exports:
|
|
159
|
+
exports_dir = out_dir / "exports"
|
|
160
|
+
exports_dir.mkdir(exist_ok=True)
|
|
161
|
+
for clause_id, events in package.audit_exports.items():
|
|
162
|
+
safe_clause = clause_id.replace("/", "_").replace(".", "_")
|
|
163
|
+
clause_path = exports_dir / f"{safe_clause}.jsonl"
|
|
164
|
+
clause_path.write_text("\n".join(json.dumps(event) for event in events), encoding="utf-8")
|
|
165
|
+
print(f"[✓] Clause exports → {exports_dir}/ ({len(package.audit_exports)} clause(s))")
|
|
166
|
+
|
|
167
|
+
print(f"\nOverall status: {package.attestation.overall_status.value}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_validate_attestation(args: argparse.Namespace) -> int:
|
|
172
|
+
"""Implement ``spanforge compliance validate-attestation``."""
|
|
173
|
+
from spanforge.compliance import verify_attestation_signature
|
|
174
|
+
from spanforge.core.compliance_mapping import ComplianceAttestation
|
|
175
|
+
|
|
176
|
+
attestation_path = Path(args.attestation_file)
|
|
177
|
+
if not attestation_path.exists():
|
|
178
|
+
print(f"error: file not found: {attestation_path}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = json.loads(attestation_path.read_text(encoding="utf-8"))
|
|
183
|
+
except json.JSONDecodeError as exc:
|
|
184
|
+
print(f"error: invalid JSON in {attestation_path}: {exc}", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
attestation = _attestation_from_dict(data)
|
|
189
|
+
except (KeyError, ValueError) as exc:
|
|
190
|
+
print(f"error: could not parse attestation: {exc}", file=sys.stderr)
|
|
191
|
+
return 2
|
|
192
|
+
|
|
193
|
+
assert isinstance(attestation, ComplianceAttestation) # nosec B101
|
|
194
|
+
if verify_attestation_signature(attestation):
|
|
195
|
+
print(f"[✓] Attestation signature is valid model_id={data.get('model_id')!r}")
|
|
196
|
+
return 0
|
|
197
|
+
print(
|
|
198
|
+
f"[✗] Attestation signature is INVALID model_id={data.get('model_id')!r}",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
205
|
+
"""Implement ``spanforge compliance report``."""
|
|
206
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine
|
|
207
|
+
|
|
208
|
+
framework_key, framework = _resolve_framework(args.framework)
|
|
209
|
+
if framework is None:
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
status, audit_events = _load_audit_events(getattr(args, "events_file", None))
|
|
213
|
+
if status != 0:
|
|
214
|
+
return status
|
|
215
|
+
|
|
216
|
+
engine = ComplianceMappingEngine()
|
|
217
|
+
try:
|
|
218
|
+
package = engine.generate_evidence_package(
|
|
219
|
+
model_id=args.model_id,
|
|
220
|
+
framework=framework.value,
|
|
221
|
+
from_date=args.from_date,
|
|
222
|
+
to_date=args.to_date,
|
|
223
|
+
audit_events=audit_events or None,
|
|
224
|
+
)
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
print(f"error: report generation failed: {exc}", file=sys.stderr)
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
out_dir = Path(args.output)
|
|
230
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
safe_id = args.model_id.replace("/", "_")[:40]
|
|
232
|
+
prefix = f"{framework_key}_{safe_id}_{args.from_date}_{args.to_date}"
|
|
233
|
+
fmt = getattr(args, "report_format", "json")
|
|
234
|
+
|
|
235
|
+
if fmt in ("json", "both"):
|
|
236
|
+
json_path = out_dir / f"{prefix}_report.json"
|
|
237
|
+
json_path.write_text(package.to_json(), encoding="utf-8")
|
|
238
|
+
print(f"[✓] JSON report → {json_path}")
|
|
239
|
+
|
|
240
|
+
if fmt in ("markdown", "both"):
|
|
241
|
+
md_path = out_dir / f"{prefix}_report.md"
|
|
242
|
+
md_path.write_text(package.to_markdown(), encoding="utf-8")
|
|
243
|
+
print(f"[✓] Markdown report → {md_path}")
|
|
244
|
+
|
|
245
|
+
if fmt in ("pdf", "both"):
|
|
246
|
+
pdf_path = out_dir / f"{prefix}_report.pdf"
|
|
247
|
+
try:
|
|
248
|
+
package.to_pdf(str(pdf_path))
|
|
249
|
+
print(f"[✓] PDF report → {pdf_path}")
|
|
250
|
+
except ImportError:
|
|
251
|
+
print(
|
|
252
|
+
"error: PDF generation requires reportlab. Install: pip install spanforge[compliance]",
|
|
253
|
+
file=sys.stderr,
|
|
254
|
+
)
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
overall = package.attestation.overall_status.value
|
|
258
|
+
print(f"\nOverall status: {overall.upper()}")
|
|
259
|
+
return 0 if overall == "pass" else 1
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
263
|
+
"""Implement ``spanforge compliance check``."""
|
|
264
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine
|
|
265
|
+
|
|
266
|
+
status, audit_events = _load_audit_events(getattr(args, "events_file", None))
|
|
267
|
+
if status != 0:
|
|
268
|
+
return status
|
|
269
|
+
|
|
270
|
+
engine = ComplianceMappingEngine()
|
|
271
|
+
try:
|
|
272
|
+
package = engine.generate_evidence_package(
|
|
273
|
+
model_id=args.model_id,
|
|
274
|
+
framework=args.framework,
|
|
275
|
+
from_date=args.from_date,
|
|
276
|
+
to_date=args.to_date,
|
|
277
|
+
audit_events=audit_events or None,
|
|
278
|
+
)
|
|
279
|
+
except ValueError as exc:
|
|
280
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
281
|
+
return 2
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
print(f"error: compliance check failed: {exc}", file=sys.stderr)
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
allow_partial = getattr(args, "allow_partial", False)
|
|
287
|
+
gap = package.gap_report
|
|
288
|
+
overall = package.attestation.overall_status.value
|
|
289
|
+
|
|
290
|
+
for record in package.attestation.clauses:
|
|
291
|
+
icon = {"pass": "[✓]", "fail": "[✗]", "partial": "[~]"}.get(record.status.value, "[?]") # nosec B105
|
|
292
|
+
print(f" {icon} {record.clause_id:<20} {record.status.value:<8} {record.evidence_count} events")
|
|
293
|
+
|
|
294
|
+
print(f"\nOverall: {overall.upper()}")
|
|
295
|
+
|
|
296
|
+
if gap.gap_clause_ids:
|
|
297
|
+
print(f"Gaps : {', '.join(gap.gap_clause_ids)}")
|
|
298
|
+
if gap.partial_clause_ids:
|
|
299
|
+
print(f"Partial : {', '.join(gap.partial_clause_ids)}")
|
|
300
|
+
|
|
301
|
+
if gap.has_gaps and (not allow_partial or gap.gap_clause_ids):
|
|
302
|
+
print("\n[FAIL] Compliance check failed — fix gaps before deploying.", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
print("\n[PASS] Compliance check passed.")
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _build_readiness_checks(fw_slug: str) -> list[tuple[bool, str, str]]: # noqa: PLR0915
|
|
309
|
+
"""Build the list of (passed, label, fix) tuples for *cmd_readiness*."""
|
|
310
|
+
import os
|
|
311
|
+
|
|
312
|
+
checks: list[tuple[bool, str, str]] = []
|
|
313
|
+
|
|
314
|
+
# Check 1: signing key
|
|
315
|
+
signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
316
|
+
signing_ok = bool(signing_key) and signing_key not in {
|
|
317
|
+
"spanforge-default",
|
|
318
|
+
"spanforge-insecure-default-do-not-use-in-production",
|
|
319
|
+
}
|
|
320
|
+
checks.append((
|
|
321
|
+
signing_ok,
|
|
322
|
+
"SPANFORGE_SIGNING_KEY is set to a non-default value",
|
|
323
|
+
"export SPANFORGE_SIGNING_KEY=$(openssl rand -hex 32)",
|
|
324
|
+
))
|
|
325
|
+
|
|
326
|
+
# Check 2: durable exporter
|
|
327
|
+
exporter_name = "unknown"
|
|
328
|
+
durable = False
|
|
329
|
+
try:
|
|
330
|
+
from spanforge.config import get_config
|
|
331
|
+
cfg = get_config()
|
|
332
|
+
exporter_name = getattr(cfg, "exporter", "console") or "console"
|
|
333
|
+
durable = exporter_name not in ("console", "")
|
|
334
|
+
except Exception: # nosec B110
|
|
335
|
+
pass
|
|
336
|
+
checks.append((
|
|
337
|
+
durable,
|
|
338
|
+
f"Durable exporter configured (current: {exporter_name!r})",
|
|
339
|
+
"spanforge.configure(exporter='sqlite', endpoint='./spanforge.db')",
|
|
340
|
+
))
|
|
341
|
+
|
|
342
|
+
# Check 3: PII redaction
|
|
343
|
+
pii_on = False
|
|
344
|
+
try:
|
|
345
|
+
from spanforge.config import get_config
|
|
346
|
+
cfg = get_config()
|
|
347
|
+
pii_on = bool(getattr(cfg, "redact_pii", False))
|
|
348
|
+
except Exception: # nosec B110
|
|
349
|
+
pass
|
|
350
|
+
checks.append((
|
|
351
|
+
pii_on,
|
|
352
|
+
"PII redaction enabled (redact_pii=True)",
|
|
353
|
+
"spanforge.configure(redact_pii=True)",
|
|
354
|
+
))
|
|
355
|
+
|
|
356
|
+
# Framework-specific
|
|
357
|
+
if fw_slug in ("eu_ai_act", "gdpr"):
|
|
358
|
+
explain_available = False
|
|
359
|
+
try:
|
|
360
|
+
from spanforge.sdk import sf_explain # noqa: F401
|
|
361
|
+
explain_available = True
|
|
362
|
+
except Exception: # nosec B110
|
|
363
|
+
pass
|
|
364
|
+
checks.append((
|
|
365
|
+
explain_available,
|
|
366
|
+
"sf_explain module available",
|
|
367
|
+
"pip install spanforge # sf_explain is bundled",
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
if fw_slug in ("eu_ai_act", "gdpr", "nist_ai_rmf"):
|
|
371
|
+
drift_on = False
|
|
372
|
+
try:
|
|
373
|
+
from spanforge.config import get_config
|
|
374
|
+
cfg = get_config()
|
|
375
|
+
drift_on = bool(getattr(cfg, "drift_detection", False))
|
|
376
|
+
except Exception: # nosec B110
|
|
377
|
+
pass
|
|
378
|
+
checks.append((
|
|
379
|
+
drift_on,
|
|
380
|
+
"Drift detection enabled (drift_detection=True)",
|
|
381
|
+
"spanforge.configure(drift_detection=True)",
|
|
382
|
+
))
|
|
383
|
+
|
|
384
|
+
if fw_slug in ("eu_ai_act", "gdpr"):
|
|
385
|
+
hitl_available = False
|
|
386
|
+
try:
|
|
387
|
+
from spanforge.sdk import sf_hitl # type: ignore[attr-defined] # noqa: F401
|
|
388
|
+
hitl_available = True
|
|
389
|
+
except Exception: # nosec B110
|
|
390
|
+
pass
|
|
391
|
+
checks.append((
|
|
392
|
+
hitl_available,
|
|
393
|
+
"sf_hitl (human-in-the-loop) module available",
|
|
394
|
+
"pip install spanforge # sf_hitl is bundled",
|
|
395
|
+
))
|
|
396
|
+
|
|
397
|
+
if fw_slug in ("soc2", "hipaa", "nist_ai_rmf", "iso_42001"):
|
|
398
|
+
eval_on = False
|
|
399
|
+
try:
|
|
400
|
+
from spanforge.config import get_config
|
|
401
|
+
cfg = get_config()
|
|
402
|
+
eval_on = bool(getattr(cfg, "track_eval", False))
|
|
403
|
+
except Exception: # nosec B110
|
|
404
|
+
pass
|
|
405
|
+
checks.append((
|
|
406
|
+
eval_on,
|
|
407
|
+
"Eval tracking enabled (track_eval=True)",
|
|
408
|
+
"spanforge.configure(track_eval=True)",
|
|
409
|
+
))
|
|
410
|
+
|
|
411
|
+
if fw_slug in ("soc2", "hipaa", "iso_42001"):
|
|
412
|
+
cost_on = False
|
|
413
|
+
try:
|
|
414
|
+
from spanforge.config import get_config
|
|
415
|
+
cfg = get_config()
|
|
416
|
+
cost_on = bool(getattr(cfg, "track_cost", False))
|
|
417
|
+
except Exception: # nosec B110
|
|
418
|
+
pass
|
|
419
|
+
checks.append((
|
|
420
|
+
cost_on,
|
|
421
|
+
"Cost tracking enabled (track_cost=True)",
|
|
422
|
+
"spanforge.configure(track_cost=True)",
|
|
423
|
+
))
|
|
424
|
+
|
|
425
|
+
return checks
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_readiness(args: argparse.Namespace) -> int:
|
|
429
|
+
"""Implement ``spanforge compliance readiness``.
|
|
430
|
+
|
|
431
|
+
Checks the current SpanForge configuration against the requirements
|
|
432
|
+
for a target framework — *without* needing any events. Answers:
|
|
433
|
+
"What do I need to turn on before I hire an auditor?"
|
|
434
|
+
"""
|
|
435
|
+
framework_key, framework = _resolve_framework(getattr(args, "framework", "eu_ai_act"))
|
|
436
|
+
if framework is None:
|
|
437
|
+
return 2
|
|
438
|
+
|
|
439
|
+
pass_marker = "[✓]" # noqa: S105 # nosec B105
|
|
440
|
+
fail_marker = "[✗]"
|
|
441
|
+
warn_marker = "[!]"
|
|
442
|
+
|
|
443
|
+
checks = _build_readiness_checks(framework_key.lower())
|
|
444
|
+
|
|
445
|
+
# --- Render ---
|
|
446
|
+
print(f"SpanForge Compliance Readiness — {framework.value}")
|
|
447
|
+
print("=" * 56)
|
|
448
|
+
failures = 0
|
|
449
|
+
for passed, label, fix in checks:
|
|
450
|
+
if passed:
|
|
451
|
+
print(f" {pass_marker} {label}")
|
|
452
|
+
else:
|
|
453
|
+
print(f" {fail_marker} {label}")
|
|
454
|
+
print(f" Fix: {fix}")
|
|
455
|
+
failures += 1
|
|
456
|
+
|
|
457
|
+
score = len(checks) - failures
|
|
458
|
+
pct = int(score / len(checks) * 100) if checks else 0
|
|
459
|
+
print("")
|
|
460
|
+
print(f"Readiness: {score}/{len(checks)} checks passing ({pct}%)")
|
|
461
|
+
|
|
462
|
+
if failures == 0:
|
|
463
|
+
print(f"\n{pass_marker} Ready to begin a {framework.value} audit engagement.")
|
|
464
|
+
return 0
|
|
465
|
+
print(f"\n{warn_marker} Fix {failures} item(s) above before starting your audit engagement.")
|
|
466
|
+
return 1
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
470
|
+
"""Implement ``spanforge compliance status``."""
|
|
471
|
+
from spanforge.redact import scan_payload
|
|
472
|
+
from spanforge.signing import verify_chain
|
|
473
|
+
|
|
474
|
+
events_path = Path(args.events_file)
|
|
475
|
+
if not events_path.exists():
|
|
476
|
+
print(f"error: events file not found: {events_path}", file=sys.stderr)
|
|
477
|
+
return 2
|
|
478
|
+
|
|
479
|
+
raw_events: list[dict[str, Any]] = []
|
|
480
|
+
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
481
|
+
line = line.strip()
|
|
482
|
+
if not line:
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
raw_events.append(json.loads(line))
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
continue
|
|
488
|
+
|
|
489
|
+
if not raw_events:
|
|
490
|
+
print("error: no events found in file", file=sys.stderr)
|
|
491
|
+
return 1
|
|
492
|
+
|
|
493
|
+
signing_key = os.environ.get("SPANFORGE_SIGNING_KEY", "")
|
|
494
|
+
chain_ok = False
|
|
495
|
+
chain_msg = "no signing key"
|
|
496
|
+
if signing_key:
|
|
497
|
+
try:
|
|
498
|
+
chain_result = verify_chain(raw_events, signing_key) # type: ignore[arg-type]
|
|
499
|
+
chain_ok = chain_result.valid
|
|
500
|
+
chain_msg = (
|
|
501
|
+
"valid" if chain_result.valid else f"broken at event {chain_result.first_tampered}"
|
|
502
|
+
)
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
chain_msg = f"error: {exc}"
|
|
505
|
+
|
|
506
|
+
pii_clean = True
|
|
507
|
+
pii_hits = 0
|
|
508
|
+
for event in raw_events:
|
|
509
|
+
payload = event.get("payload", {})
|
|
510
|
+
if isinstance(payload, dict):
|
|
511
|
+
scan_result = scan_payload(payload)
|
|
512
|
+
if not scan_result.clean:
|
|
513
|
+
pii_clean = False
|
|
514
|
+
pii_hits += len(scan_result.hits)
|
|
515
|
+
|
|
516
|
+
clause_summary: dict[str, Any] = {}
|
|
517
|
+
try:
|
|
518
|
+
from spanforge.core.compliance_mapping import ComplianceMappingEngine
|
|
519
|
+
|
|
520
|
+
engine = ComplianceMappingEngine()
|
|
521
|
+
package = engine.generate_evidence_package(
|
|
522
|
+
model_id="*",
|
|
523
|
+
framework=args.framework,
|
|
524
|
+
from_date="2000-01-01",
|
|
525
|
+
to_date="2099-12-31",
|
|
526
|
+
audit_events=raw_events,
|
|
527
|
+
)
|
|
528
|
+
for record in package.attestation.clauses:
|
|
529
|
+
clause_summary[record.clause_id] = {
|
|
530
|
+
"status": record.status.value,
|
|
531
|
+
"evidence_count": record.evidence_count,
|
|
532
|
+
}
|
|
533
|
+
except Exception:
|
|
534
|
+
clause_summary = {"error": "could not evaluate clause coverage"}
|
|
535
|
+
|
|
536
|
+
last_attestation: str | None = None
|
|
537
|
+
for event in reversed(raw_events):
|
|
538
|
+
event_type = event.get("event_type", "")
|
|
539
|
+
if "attestation" in event_type.lower() or "compliance" in event_type.lower():
|
|
540
|
+
last_attestation = event.get("timestamp")
|
|
541
|
+
break
|
|
542
|
+
|
|
543
|
+
summary = {
|
|
544
|
+
"chain_integrity": {"valid": chain_ok, "message": chain_msg},
|
|
545
|
+
"pii_scan": {"clean": pii_clean, "hit_count": pii_hits},
|
|
546
|
+
"clause_coverage": clause_summary,
|
|
547
|
+
"last_attestation_timestamp": last_attestation,
|
|
548
|
+
"events_analysed": len(raw_events),
|
|
549
|
+
}
|
|
550
|
+
print(json.dumps(summary, indent=2, default=str))
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def add_compliance_subcommands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser:
|
|
555
|
+
"""Register the compliance subcommands on the top-level parser."""
|
|
556
|
+
compliance_parser = subparsers.add_parser(
|
|
557
|
+
"compliance",
|
|
558
|
+
help="Compliance evidence generation and attestation validation",
|
|
559
|
+
)
|
|
560
|
+
comp_sub = compliance_parser.add_subparsers(dest="compliance_command", metavar="<action>")
|
|
561
|
+
|
|
562
|
+
gen_parser = comp_sub.add_parser(
|
|
563
|
+
"generate",
|
|
564
|
+
help="Generate a compliance evidence package for a model/framework/period",
|
|
565
|
+
)
|
|
566
|
+
gen_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
|
|
567
|
+
gen_parser.add_argument(
|
|
568
|
+
"--framework",
|
|
569
|
+
required=True,
|
|
570
|
+
help="Compliance framework (eu_ai_act, gdpr, iso_42001, nist_ai_rmf, soc2)",
|
|
571
|
+
)
|
|
572
|
+
gen_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
|
|
573
|
+
gen_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
|
|
574
|
+
gen_parser.add_argument(
|
|
575
|
+
"--output",
|
|
576
|
+
default=".",
|
|
577
|
+
metavar="DIR",
|
|
578
|
+
help="Output directory for evidence files (default: .)",
|
|
579
|
+
)
|
|
580
|
+
gen_parser.add_argument(
|
|
581
|
+
"--events-file",
|
|
582
|
+
dest="events_file",
|
|
583
|
+
metavar="JSONL",
|
|
584
|
+
help="Optional JSONL file of audit events to include",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
val_att_parser = comp_sub.add_parser(
|
|
588
|
+
"validate-attestation",
|
|
589
|
+
help="Verify the HMAC signature of a compliance attestation JSON file",
|
|
590
|
+
)
|
|
591
|
+
val_att_parser.add_argument(
|
|
592
|
+
"attestation_file",
|
|
593
|
+
metavar="ATTESTATION_JSON",
|
|
594
|
+
help="Path to a compliance attestation JSON file",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
report_parser = comp_sub.add_parser(
|
|
598
|
+
"report",
|
|
599
|
+
help="Generate a compliance report (JSON, PDF, or both) with HMAC attestation",
|
|
600
|
+
)
|
|
601
|
+
report_parser.add_argument("--model-id", dest="model_id", required=True, help="Model UUID")
|
|
602
|
+
report_parser.add_argument(
|
|
603
|
+
"--framework",
|
|
604
|
+
required=True,
|
|
605
|
+
help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
|
|
606
|
+
)
|
|
607
|
+
report_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
|
|
608
|
+
report_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
|
|
609
|
+
report_parser.add_argument(
|
|
610
|
+
"--format",
|
|
611
|
+
dest="report_format",
|
|
612
|
+
default="json",
|
|
613
|
+
choices=["json", "pdf", "markdown", "both"],
|
|
614
|
+
help="Output format: json, pdf, markdown, or both (default: json)",
|
|
615
|
+
)
|
|
616
|
+
report_parser.add_argument(
|
|
617
|
+
"--output",
|
|
618
|
+
default=".",
|
|
619
|
+
metavar="DIR",
|
|
620
|
+
help="Output directory (default: .)",
|
|
621
|
+
)
|
|
622
|
+
report_parser.add_argument(
|
|
623
|
+
"--events-file",
|
|
624
|
+
dest="events_file",
|
|
625
|
+
metavar="JSONL",
|
|
626
|
+
help="Optional JSONL file of audit events to include",
|
|
627
|
+
)
|
|
628
|
+
report_parser.add_argument(
|
|
629
|
+
"--sign",
|
|
630
|
+
action="store_true",
|
|
631
|
+
default=False,
|
|
632
|
+
help="Embed HMAC attestation signature in the output",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
check_parser = comp_sub.add_parser(
|
|
636
|
+
"check",
|
|
637
|
+
help="CI-friendly compliance gate: exits 0 if all clauses pass, 1 if gaps exist",
|
|
638
|
+
)
|
|
639
|
+
check_parser.add_argument(
|
|
640
|
+
"--model-id",
|
|
641
|
+
dest="model_id",
|
|
642
|
+
default="*",
|
|
643
|
+
help="Model ID to check (default: * = all models)",
|
|
644
|
+
)
|
|
645
|
+
check_parser.add_argument(
|
|
646
|
+
"--framework",
|
|
647
|
+
required=True,
|
|
648
|
+
help="Compliance framework (eu_ai_act, gdpr, hipaa, iso_42001, nist_ai_rmf, soc2)",
|
|
649
|
+
)
|
|
650
|
+
check_parser.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="Period start date (YYYY-MM-DD)")
|
|
651
|
+
check_parser.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="Period end date (YYYY-MM-DD)")
|
|
652
|
+
check_parser.add_argument(
|
|
653
|
+
"--events-file",
|
|
654
|
+
dest="events_file",
|
|
655
|
+
metavar="JSONL",
|
|
656
|
+
help="Optional JSONL file of audit events",
|
|
657
|
+
)
|
|
658
|
+
check_parser.add_argument(
|
|
659
|
+
"--allow-partial",
|
|
660
|
+
dest="allow_partial",
|
|
661
|
+
action="store_true",
|
|
662
|
+
help="Exit 0 on partial coverage (only fail on zero-evidence clauses)",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
readiness_parser = comp_sub.add_parser(
|
|
666
|
+
"readiness",
|
|
667
|
+
help="Pre-audit config check: are you ready to start a compliance engagement?",
|
|
668
|
+
)
|
|
669
|
+
readiness_parser.add_argument(
|
|
670
|
+
"--framework",
|
|
671
|
+
default="eu_ai_act",
|
|
672
|
+
help="Target framework to check readiness for (default: eu_ai_act)",
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
status_parser = comp_sub.add_parser(
|
|
676
|
+
"status",
|
|
677
|
+
help="Output a single JSON summary of compliance posture",
|
|
678
|
+
)
|
|
679
|
+
status_parser.add_argument(
|
|
680
|
+
"--events-file",
|
|
681
|
+
dest="events_file",
|
|
682
|
+
required=True,
|
|
683
|
+
metavar="JSONL",
|
|
684
|
+
help="JSONL file of audit events to analyse",
|
|
685
|
+
)
|
|
686
|
+
status_parser.add_argument(
|
|
687
|
+
"--framework",
|
|
688
|
+
default="eu_ai_act",
|
|
689
|
+
help="Compliance framework (default: eu_ai_act)",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return compliance_parser
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def dispatch_compliance_command(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
696
|
+
"""Dispatch the compliance command group."""
|
|
697
|
+
action = getattr(args, "compliance_command", None)
|
|
698
|
+
import argparse as _ap
|
|
699
|
+
_dispatch: dict[str, Callable[[_ap.Namespace], int]] = {
|
|
700
|
+
"generate": cmd_generate,
|
|
701
|
+
"validate-attestation": cmd_validate_attestation,
|
|
702
|
+
"check": cmd_check,
|
|
703
|
+
"report": cmd_report,
|
|
704
|
+
"status": cmd_status,
|
|
705
|
+
"readiness": cmd_readiness,
|
|
706
|
+
}
|
|
707
|
+
handler = _dispatch.get(action or "")
|
|
708
|
+
if handler is not None:
|
|
709
|
+
return handler(args)
|
|
710
|
+
parser.print_help()
|
|
711
|
+
return 2
|