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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,639 @@
1
+ """Audit command group for the SpanForge CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ ReadJsonlEvents = Callable[[Path], list[tuple[int, Any]]]
10
+
11
+
12
+ def add_audit_subcommands(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> argparse.ArgumentParser:
13
+ """Register audit-related CLI subcommands."""
14
+ audit_parser = sub.add_parser(
15
+ "audit-chain",
16
+ help="Verify HMAC signing chain integrity of events in a JSONL file",
17
+ )
18
+ audit_parser.add_argument(
19
+ "file",
20
+ metavar="EVENTS_JSONL",
21
+ help="Path to a JSONL file of signed events (reads SPANFORGE_SIGNING_KEY env var)",
22
+ )
23
+
24
+ audit_group_parser = sub.add_parser(
25
+ "audit",
26
+ help="Audit chain management (erase, check-health)",
27
+ )
28
+ audit_sub = audit_group_parser.add_subparsers(dest="audit_command", metavar="<action>")
29
+
30
+ erase_parser = audit_sub.add_parser(
31
+ "erase",
32
+ help="GDPR subject erasure: replace events mentioning a subject with tombstones",
33
+ )
34
+ erase_parser.add_argument(
35
+ "file",
36
+ metavar="EVENTS_JSONL",
37
+ help="Path to the JSONL audit file",
38
+ )
39
+ erase_parser.add_argument(
40
+ "--subject-id",
41
+ dest="subject_id",
42
+ required=True,
43
+ help="The data-subject identifier to erase",
44
+ )
45
+ erase_parser.add_argument(
46
+ "--erased-by",
47
+ dest="erased_by",
48
+ default="cli",
49
+ help="Identity of the operator performing erasure (default: cli)",
50
+ )
51
+ erase_parser.add_argument(
52
+ "--reason",
53
+ default="GDPR Art.17 right to erasure",
54
+ help="Reason for erasure (default: 'GDPR Art.17 right to erasure')",
55
+ )
56
+ erase_parser.add_argument(
57
+ "--request-ref",
58
+ dest="request_ref",
59
+ default="",
60
+ help="External erasure request reference (e.g. ticket ID)",
61
+ )
62
+ erase_parser.add_argument(
63
+ "--output",
64
+ default=None,
65
+ metavar="FILE",
66
+ help="Output file (required - must differ from input to prevent accidental overwrite)",
67
+ )
68
+
69
+ rotate_key_parser = audit_sub.add_parser(
70
+ "rotate-key",
71
+ help="Rotate the signing key in a JSONL audit file",
72
+ )
73
+ rotate_key_parser.add_argument(
74
+ "file",
75
+ metavar="EVENTS_JSONL",
76
+ help="Path to the JSONL audit file",
77
+ )
78
+ rotate_key_parser.add_argument(
79
+ "--new-key-env",
80
+ dest="new_key_env",
81
+ default="SPANFORGE_NEW_SIGNING_KEY",
82
+ help="Environment variable holding the new signing key (default: SPANFORGE_NEW_SIGNING_KEY)",
83
+ )
84
+ rotate_key_parser.add_argument(
85
+ "--output",
86
+ default=None,
87
+ metavar="FILE",
88
+ help="Output file (default: overwrite input file)",
89
+ )
90
+ rotate_key_parser.add_argument(
91
+ "--reason",
92
+ default="scheduled rotation",
93
+ help="Reason for key rotation (default: 'scheduled rotation')",
94
+ )
95
+
96
+ check_health_parser = audit_sub.add_parser(
97
+ "check-health",
98
+ help="Run health checks on a JSONL audit file",
99
+ )
100
+ check_health_parser.add_argument(
101
+ "file",
102
+ metavar="EVENTS_JSONL",
103
+ help="Path to the JSONL audit file",
104
+ )
105
+ check_health_parser.add_argument(
106
+ "--output",
107
+ choices=["text", "json"],
108
+ default="text",
109
+ help="Output format (default: text)",
110
+ )
111
+
112
+ verify_parser = audit_sub.add_parser(
113
+ "verify",
114
+ help="Verify HMAC chain integrity of JSONL audit file(s)",
115
+ )
116
+ verify_parser.add_argument(
117
+ "--input",
118
+ required=True,
119
+ help="Path to JSONL audit file (supports glob: 'audit-*.jsonl')",
120
+ )
121
+ verify_parser.add_argument(
122
+ "--key",
123
+ default=None,
124
+ help="HMAC signing key (default: $SPANFORGE_SIGNING_KEY)",
125
+ )
126
+
127
+ return audit_group_parser
128
+
129
+
130
+ def dispatch_audit_command(
131
+ args: argparse.Namespace,
132
+ audit_group_parser: argparse.ArgumentParser,
133
+ read_jsonl_events: ReadJsonlEvents,
134
+ no_events_msg: str,
135
+ ) -> int | None:
136
+ """Dispatch audit-related commands when selected."""
137
+ command = getattr(args, "command", None)
138
+ if command == "audit-chain":
139
+ return _cmd_audit_chain(args, read_jsonl_events, no_events_msg)
140
+ if command != "audit":
141
+ return None
142
+
143
+ audit_action = getattr(args, "audit_command", None)
144
+ if audit_action == "erase":
145
+ return _cmd_audit_erase(args, read_jsonl_events, no_events_msg)
146
+ if audit_action == "rotate-key":
147
+ return _cmd_audit_rotate_key(args, read_jsonl_events, no_events_msg)
148
+ if audit_action == "check-health":
149
+ return _cmd_audit_check_health(args, read_jsonl_events)
150
+ if audit_action == "verify":
151
+ return _cmd_audit_verify(args, read_jsonl_events)
152
+
153
+ audit_group_parser.print_help()
154
+ return 2
155
+
156
+
157
+ def _cmd_audit_chain(
158
+ args: argparse.Namespace,
159
+ read_jsonl_events: ReadJsonlEvents,
160
+ no_events_msg: str,
161
+ ) -> int:
162
+ """Implement the ``audit-chain`` sub-command."""
163
+ import os
164
+ import sys
165
+
166
+ from spanforge.exceptions import SigningError
167
+ from spanforge.signing import verify_chain
168
+
169
+ path = Path(args.file)
170
+ if not path.exists():
171
+ print(f"error: file not found: {path}", file=sys.stderr)
172
+ return 2
173
+
174
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
175
+ if not org_secret:
176
+ print(
177
+ "error: SPANFORGE_SIGNING_KEY environment variable is not set.",
178
+ file=sys.stderr,
179
+ )
180
+ return 2
181
+
182
+ rows = read_jsonl_events(path)
183
+ if not rows:
184
+ print(no_events_msg)
185
+ return 0
186
+
187
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
188
+ if bad_lines:
189
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
190
+ for ln, exc in bad_lines[:5]:
191
+ print(f" line {ln}: {exc}", file=sys.stderr)
192
+ return 2
193
+
194
+ events = [ev for _, ev in rows]
195
+
196
+ try:
197
+ result = verify_chain(events, org_secret=org_secret)
198
+ except SigningError as exc:
199
+ print(f"error: {exc}", file=sys.stderr)
200
+ return 2
201
+
202
+ if result.valid:
203
+ print(f"OK - chain of {len(events)} event(s) is intact.")
204
+ return 0
205
+
206
+ print(f"FAIL - chain verification failed ({result.tampered_count} tampered event(s)):\n")
207
+ if result.first_tampered:
208
+ print(f" first tampered event_id: {result.first_tampered}")
209
+ if result.gaps:
210
+ print(f" linkage gaps ({len(result.gaps)}):")
211
+ for gap_id in result.gaps:
212
+ print(f" {gap_id}")
213
+ return 1
214
+
215
+
216
+ def _cmd_audit_erase(
217
+ args: argparse.Namespace,
218
+ read_jsonl_events: ReadJsonlEvents,
219
+ no_events_msg: str,
220
+ ) -> int:
221
+ """Implement ``spanforge audit erase``."""
222
+ import os
223
+ import sys
224
+
225
+ from spanforge.signing import AuditStream, verify_chain
226
+
227
+ path = Path(args.file)
228
+ if not path.exists():
229
+ print(f"error: file not found: {path}", file=sys.stderr)
230
+ return 2
231
+
232
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
233
+ if not org_secret:
234
+ print("error: SPANFORGE_SIGNING_KEY environment variable is not set.", file=sys.stderr)
235
+ return 2
236
+
237
+ subject_id = args.subject_id
238
+ if not subject_id or not subject_id.strip():
239
+ print("error: --subject-id must be non-empty", file=sys.stderr)
240
+ return 2
241
+
242
+ out_path = Path(args.output) if args.output else path.with_suffix(".erased.jsonl")
243
+ if out_path.resolve() == path.resolve():
244
+ print(
245
+ "error: --output must differ from input file to prevent overwrite",
246
+ file=sys.stderr,
247
+ )
248
+ return 2
249
+
250
+ rows = read_jsonl_events(path)
251
+ if not rows:
252
+ print(no_events_msg)
253
+ return 0
254
+
255
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
256
+ if bad_lines:
257
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
258
+ for ln, exc in bad_lines[:5]:
259
+ print(f" line {ln}: {exc}", file=sys.stderr)
260
+ return 2
261
+
262
+ events = [ev for _, ev in rows]
263
+
264
+ stream = AuditStream(org_secret=org_secret, source="spanforge-cli@1.0.0")
265
+ for evt in events:
266
+ stream.append(evt)
267
+
268
+ tombstones = stream.erase_subject(
269
+ subject_id,
270
+ erased_by=getattr(args, "erased_by", "cli"),
271
+ reason=getattr(args, "reason", "GDPR Art.17 right to erasure"),
272
+ request_ref=getattr(args, "request_ref", ""),
273
+ )
274
+
275
+ if not tombstones:
276
+ print(f"No events found mentioning subject {subject_id!r}.")
277
+ return 0
278
+
279
+ chain_result = verify_chain(list(stream.events), org_secret)
280
+ if not chain_result.valid:
281
+ print(
282
+ "error: chain verification failed after erasure - aborting write",
283
+ file=sys.stderr,
284
+ )
285
+ return 2
286
+
287
+ with out_path.open("w", encoding="utf-8") as fh:
288
+ for evt in stream.events:
289
+ fh.write(evt.to_json())
290
+ fh.write("\n")
291
+
292
+ print(f"[✓] Erased {len(tombstones)} event(s) mentioning {subject_id!r}")
293
+ print(f"[✓] Updated chain written to {out_path}")
294
+ return 0
295
+
296
+
297
+ def _cmd_audit_check_health(args: argparse.Namespace, read_jsonl_events: ReadJsonlEvents) -> int:
298
+ """Implement ``spanforge audit check-health``."""
299
+ import json
300
+ import os
301
+ import sys
302
+
303
+ from spanforge.redact import scan_payload
304
+ from spanforge.signing import (
305
+ check_key_expiry,
306
+ validate_key_strength,
307
+ verify_chain,
308
+ )
309
+
310
+ path = Path(args.file)
311
+ if not path.exists():
312
+ print(f"error: file not found: {path}", file=sys.stderr)
313
+ return 2
314
+
315
+ output_fmt = getattr(args, "output", "text")
316
+ checks: list[dict[str, object]] = []
317
+ all_ok = True
318
+
319
+ checks.append({"name": "file_readable", "status": "pass", "detail": str(path)})
320
+
321
+ rows = read_jsonl_events(path)
322
+ if not rows:
323
+ checks.append({"name": "parse_events", "status": "skip", "detail": "File is empty"})
324
+ if output_fmt == "json":
325
+ print(json.dumps({"file": str(path), "checks": checks, "result": "pass"}, indent=2))
326
+ else:
327
+ print(f"Health check: {path}\n")
328
+ print("[✓] File exists and is readable")
329
+ print("[!] File is empty - no events to check")
330
+ return 0
331
+
332
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
333
+ events = [ev for _, ev in rows if not isinstance(ev, Exception)]
334
+
335
+ parse_status = "pass" if not bad_lines else "fail"
336
+ if bad_lines:
337
+ all_ok = False
338
+ checks.append(
339
+ {
340
+ "name": "parse_events",
341
+ "status": parse_status,
342
+ "detail": f"{len(events)} parsed, {len(bad_lines)} error(s)",
343
+ }
344
+ )
345
+
346
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
347
+ if org_secret and events:
348
+ result = verify_chain(events, org_secret)
349
+ if result.valid:
350
+ checks.append(
351
+ {
352
+ "name": "chain_integrity",
353
+ "status": "pass",
354
+ "detail": f"{len(events)} events verified",
355
+ }
356
+ )
357
+ else:
358
+ all_ok = False
359
+ checks.append(
360
+ {
361
+ "name": "chain_integrity",
362
+ "status": "fail",
363
+ "detail": f"{result.tampered_count} tampered, {len(result.gaps)} gap(s)",
364
+ }
365
+ )
366
+ else:
367
+ checks.append(
368
+ {
369
+ "name": "chain_integrity",
370
+ "status": "skip",
371
+ "detail": "SPANFORGE_SIGNING_KEY not set",
372
+ }
373
+ )
374
+
375
+ if org_secret:
376
+ warnings = validate_key_strength(org_secret)
377
+ if warnings:
378
+ all_ok = False
379
+ checks.append(
380
+ {
381
+ "name": "key_strength",
382
+ "status": "fail",
383
+ "detail": "; ".join(warnings),
384
+ }
385
+ )
386
+ else:
387
+ checks.append({"name": "key_strength", "status": "pass", "detail": "OK"})
388
+ else:
389
+ checks.append(
390
+ {
391
+ "name": "key_strength",
392
+ "status": "skip",
393
+ "detail": "No key to check",
394
+ }
395
+ )
396
+
397
+ expires_at = os.environ.get("SPANFORGE_SIGNING_KEY_EXPIRES_AT", "")
398
+ if expires_at:
399
+ status, days = check_key_expiry(expires_at)
400
+ if status == "expired":
401
+ all_ok = False
402
+ checks.append(
403
+ {
404
+ "name": "key_expiry",
405
+ "status": "fail",
406
+ "detail": f"EXPIRED {days} day(s) ago",
407
+ }
408
+ )
409
+ elif status == "expiring_soon":
410
+ all_ok = False
411
+ checks.append(
412
+ {
413
+ "name": "key_expiry",
414
+ "status": "fail",
415
+ "detail": f"expiring in {days} day(s)",
416
+ }
417
+ )
418
+ else:
419
+ checks.append(
420
+ {
421
+ "name": "key_expiry",
422
+ "status": "pass",
423
+ "detail": f"valid for {days} day(s)",
424
+ }
425
+ )
426
+ else:
427
+ checks.append(
428
+ {
429
+ "name": "key_expiry",
430
+ "status": "skip",
431
+ "detail": "SPANFORGE_SIGNING_KEY_EXPIRES_AT not set",
432
+ }
433
+ )
434
+
435
+ pii_hit_count = 0
436
+ for _, item in rows:
437
+ if isinstance(item, Exception):
438
+ continue
439
+ payload = getattr(item, "payload", None)
440
+ if isinstance(payload, dict):
441
+ result_pii = scan_payload(payload)
442
+ pii_hit_count += len(result_pii.hits)
443
+ if pii_hit_count:
444
+ all_ok = False
445
+ checks.append(
446
+ {
447
+ "name": "pii_scan",
448
+ "status": "fail",
449
+ "detail": f"{pii_hit_count} PII hit(s) detected",
450
+ }
451
+ )
452
+ else:
453
+ checks.append({"name": "pii_scan", "status": "pass", "detail": "No PII detected"})
454
+
455
+ from spanforge.config import get_config
456
+
457
+ try:
458
+ cfg = get_config()
459
+ if cfg.exporter:
460
+ checks.append(
461
+ {
462
+ "name": "egress_config",
463
+ "status": "pass",
464
+ "detail": f"exporter={cfg.exporter!r}",
465
+ }
466
+ )
467
+ else:
468
+ checks.append(
469
+ {
470
+ "name": "egress_config",
471
+ "status": "skip",
472
+ "detail": "No exporter configured",
473
+ }
474
+ )
475
+ except Exception as exc:
476
+ all_ok = False
477
+ checks.append(
478
+ {
479
+ "name": "egress_config",
480
+ "status": "fail",
481
+ "detail": str(exc),
482
+ }
483
+ )
484
+
485
+ if output_fmt == "json":
486
+ print(
487
+ json.dumps(
488
+ {
489
+ "file": str(path),
490
+ "events": len(events),
491
+ "errors": len(bad_lines),
492
+ "checks": checks,
493
+ "result": "pass" if all_ok else "fail",
494
+ },
495
+ indent=2,
496
+ )
497
+ )
498
+ else:
499
+ print(f"Health check: {path}\n")
500
+ for check in checks:
501
+ icon = {"pass": "✓", "fail": "!", "skip": "-"}.get(str(check.get("status", "")), "?") # nosec B105
502
+ print(f"[{icon}] {check['name']}: {check['detail']}")
503
+ print(f"\nTotal: {len(events)} events, {len(bad_lines)} errors")
504
+ print(f"Result: {'PASS' if all_ok else 'FAIL'}")
505
+
506
+ return 0 if all_ok else 1
507
+
508
+
509
+ def _cmd_audit_verify(args: argparse.Namespace, read_jsonl_events: ReadJsonlEvents) -> int:
510
+ """Implement ``spanforge audit verify``."""
511
+ import glob
512
+ import os
513
+ import sys
514
+
515
+ from spanforge.signing import verify_chain
516
+
517
+ org_secret = args.key or os.environ.get("SPANFORGE_SIGNING_KEY", "")
518
+ if not org_secret:
519
+ print(
520
+ "error: no signing key - pass --key or set SPANFORGE_SIGNING_KEY",
521
+ file=sys.stderr,
522
+ )
523
+ return 2
524
+
525
+ matched = sorted(glob.glob(args.input, recursive=True))
526
+ if not matched:
527
+ print(f"error: no files matched: {args.input}", file=sys.stderr)
528
+ return 2
529
+
530
+ all_events = []
531
+ parse_errors = 0
532
+ for fpath in matched:
533
+ rows = read_jsonl_events(Path(fpath))
534
+ for _lineno, item in rows:
535
+ if isinstance(item, Exception):
536
+ parse_errors += 1
537
+ else:
538
+ all_events.append(item)
539
+
540
+ if not all_events:
541
+ print("error: no events found in matched files", file=sys.stderr)
542
+ return 2
543
+
544
+ result = verify_chain(all_events, org_secret)
545
+
546
+ print(f"Files checked : {len(matched)}")
547
+ print(f"Total events : {len(all_events)}")
548
+ if parse_errors:
549
+ print(f"Parse errors : {parse_errors}")
550
+ if result.tombstone_count:
551
+ print(f"Tombstones : {result.tombstone_count}")
552
+ print(f"Tampered : {result.tampered_count}")
553
+ print(f"Gaps : {len(result.gaps)}")
554
+ if result.first_tampered:
555
+ print(f"First tampered: {result.first_tampered}")
556
+ if result.gaps:
557
+ print(f"Gap event IDs : {', '.join(result.gaps[:10])}")
558
+ if len(result.gaps) > 10:
559
+ print(f" ... and {len(result.gaps) - 10} more")
560
+
561
+ if result.valid:
562
+ print("\nResult: PASS")
563
+ return 0
564
+
565
+ print("\nResult: FAIL")
566
+ return 1
567
+
568
+
569
+ def _cmd_audit_rotate_key(
570
+ args: argparse.Namespace,
571
+ read_jsonl_events: ReadJsonlEvents,
572
+ no_events_msg: str,
573
+ ) -> int:
574
+ """Implement ``spanforge audit rotate-key``."""
575
+ import os
576
+ import sys
577
+
578
+ from spanforge.signing import AuditStream, verify_chain
579
+
580
+ path = Path(args.file)
581
+ if not path.exists():
582
+ print(f"error: file not found: {path}", file=sys.stderr)
583
+ return 2
584
+
585
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
586
+ if not org_secret:
587
+ print("error: SPANFORGE_SIGNING_KEY environment variable is not set.", file=sys.stderr)
588
+ return 2
589
+
590
+ new_key_env = getattr(args, "new_key_env", "SPANFORGE_NEW_SIGNING_KEY")
591
+ new_secret = os.environ.get(new_key_env, "")
592
+ if not new_secret:
593
+ print(f"error: {new_key_env} environment variable is not set.", file=sys.stderr)
594
+ return 2
595
+
596
+ rows = read_jsonl_events(path)
597
+ if not rows:
598
+ print(no_events_msg)
599
+ return 0
600
+
601
+ bad_lines = [(ln, exc) for ln, exc in rows if isinstance(exc, Exception)]
602
+ if bad_lines:
603
+ print(f"error: {len(bad_lines)} line(s) could not be parsed:", file=sys.stderr)
604
+ for ln, exc in bad_lines[:5]:
605
+ print(f" line {ln}: {exc}", file=sys.stderr)
606
+ return 2
607
+
608
+ events = [ev for _, ev in rows]
609
+
610
+ stream = AuditStream(org_secret=org_secret, source="spanforge-cli@1.0.0")
611
+ for evt in events:
612
+ stream.append(evt)
613
+
614
+ reason = getattr(args, "reason", "scheduled rotation")
615
+ stream.rotate_key(new_secret, metadata={"reason": reason, "rotated_by": "cli"})
616
+
617
+ explicit_output = getattr(args, "output", None)
618
+ out_path = Path(explicit_output) if explicit_output else path.with_suffix(".rotated.jsonl")
619
+
620
+ with out_path.open("w", encoding="utf-8") as fh:
621
+ for evt in stream.events:
622
+ fh.write(evt.to_json())
623
+ fh.write("\n")
624
+
625
+ print(f"[✓] Key rotated - chain rewritten to {out_path}")
626
+
627
+ rotated_events = stream.events
628
+ verify_result = verify_chain(rotated_events, new_secret)
629
+ if verify_result.valid:
630
+ print(f"[✓] Re-verification: chain valid ({len(rotated_events)} events)")
631
+ else:
632
+ print(
633
+ f"[!] Re-verification: FAILED - {verify_result.tampered_count} tampered, "
634
+ f"{len(verify_result.gaps)} gap(s)"
635
+ )
636
+ return 1
637
+
638
+ print(f"[✓] Update SPANFORGE_SIGNING_KEY to the value of {new_key_env}")
639
+ return 0