spanforge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_cli_audit.py
ADDED
|
@@ -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
|