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,264 @@
|
|
|
1
|
+
"""spanforge.export.siem_splunk — Splunk HTTP Event Collector (HEC) exporter.
|
|
2
|
+
|
|
3
|
+
Forwards spanforge events to a Splunk HTTP Event Collector endpoint.
|
|
4
|
+
|
|
5
|
+
Configuration
|
|
6
|
+
-------------
|
|
7
|
+
``SPANFORGE_SPLUNK_HEC_URL``
|
|
8
|
+
Required. Full URL of the Splunk HEC endpoint, e.g.
|
|
9
|
+
``https://splunk.example.com:8088/services/collector/event``.
|
|
10
|
+
|
|
11
|
+
``SPANFORGE_SPLUNK_HEC_TOKEN``
|
|
12
|
+
Required. Splunk HEC authentication token (``Splunk <token>``).
|
|
13
|
+
|
|
14
|
+
``SPANFORGE_SPLUNK_INDEX``
|
|
15
|
+
Optional. Splunk index to route events to. Default: ``"main"``.
|
|
16
|
+
|
|
17
|
+
``SPANFORGE_SPLUNK_SOURCE``
|
|
18
|
+
Optional. Splunk ``source`` field. Default: ``"spanforge"``.
|
|
19
|
+
|
|
20
|
+
``SPANFORGE_SPLUNK_SOURCETYPE``
|
|
21
|
+
Optional. Splunk ``sourcetype`` field. Default: ``"spanforge:event"``.
|
|
22
|
+
|
|
23
|
+
``SPANFORGE_SPLUNK_BATCH_SIZE``
|
|
24
|
+
Optional integer. Events per HEC request. Default: ``50``.
|
|
25
|
+
|
|
26
|
+
``SPANFORGE_SPLUNK_TIMEOUT``
|
|
27
|
+
Optional float (seconds). HTTP request timeout. Default: ``10.0``.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
os.environ["SPANFORGE_SPLUNK_HEC_URL"] = "https://splunk:8088/services/collector/event"
|
|
33
|
+
os.environ["SPANFORGE_SPLUNK_HEC_TOKEN"] = "your-token-here"
|
|
34
|
+
|
|
35
|
+
from spanforge.export.siem_splunk import SplunkHECExporter
|
|
36
|
+
exporter = SplunkHECExporter()
|
|
37
|
+
exporter.export(event)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
import ssl
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
import urllib.error
|
|
49
|
+
import urllib.request
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
from spanforge.export.siem_schema import event_to_siem_record
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from collections.abc import Sequence
|
|
56
|
+
|
|
57
|
+
from spanforge.event import Event
|
|
58
|
+
|
|
59
|
+
__all__ = ["SplunkHECError", "SplunkHECExporter"]
|
|
60
|
+
|
|
61
|
+
_log = logging.getLogger("spanforge.export.siem_splunk")
|
|
62
|
+
|
|
63
|
+
_DEFAULT_BATCH_SIZE = 50
|
|
64
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
65
|
+
_DEFAULT_INDEX = "main"
|
|
66
|
+
_DEFAULT_SOURCE = "spanforge"
|
|
67
|
+
_DEFAULT_SOURCETYPE = "spanforge:event"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SplunkHECError(RuntimeError):
|
|
71
|
+
"""Raised when a Splunk HEC delivery attempt fails permanently."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SplunkHECExporter:
|
|
75
|
+
"""Export spanforge events to a Splunk HTTP Event Collector endpoint.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
hec_url: Splunk HEC URL. Falls back to ``SPANFORGE_SPLUNK_HEC_URL``.
|
|
79
|
+
token: HEC authentication token. Falls back to
|
|
80
|
+
``SPANFORGE_SPLUNK_HEC_TOKEN``.
|
|
81
|
+
index: Splunk index. Falls back to ``SPANFORGE_SPLUNK_INDEX``.
|
|
82
|
+
source: Splunk source field. Falls back to ``SPANFORGE_SPLUNK_SOURCE``.
|
|
83
|
+
sourcetype: Splunk sourcetype field. Falls back to
|
|
84
|
+
``SPANFORGE_SPLUNK_SOURCETYPE``.
|
|
85
|
+
batch_size: Events per HTTP request. Falls back to
|
|
86
|
+
``SPANFORGE_SPLUNK_BATCH_SIZE`` (default 50).
|
|
87
|
+
timeout: HTTP request timeout in seconds. Falls back to
|
|
88
|
+
``SPANFORGE_SPLUNK_TIMEOUT`` (default 10.0).
|
|
89
|
+
verify_ssl: Whether to verify the server TLS certificate. Default
|
|
90
|
+
``True``; set to ``False`` only in controlled lab
|
|
91
|
+
environments.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
hec_url: str = "",
|
|
98
|
+
token: str = "",
|
|
99
|
+
index: str = "",
|
|
100
|
+
source: str = "",
|
|
101
|
+
sourcetype: str = "",
|
|
102
|
+
batch_size: int = 0,
|
|
103
|
+
timeout: float = 0.0,
|
|
104
|
+
verify_ssl: bool = True,
|
|
105
|
+
) -> None:
|
|
106
|
+
self._hec_url: str = hec_url or os.environ.get("SPANFORGE_SPLUNK_HEC_URL", "")
|
|
107
|
+
self._token: str = token or os.environ.get("SPANFORGE_SPLUNK_HEC_TOKEN", "")
|
|
108
|
+
self._index: str = index or os.environ.get("SPANFORGE_SPLUNK_INDEX", _DEFAULT_INDEX)
|
|
109
|
+
self._source: str = source or os.environ.get("SPANFORGE_SPLUNK_SOURCE", _DEFAULT_SOURCE)
|
|
110
|
+
self._sourcetype: str = sourcetype or os.environ.get(
|
|
111
|
+
"SPANFORGE_SPLUNK_SOURCETYPE", _DEFAULT_SOURCETYPE
|
|
112
|
+
)
|
|
113
|
+
self._batch_size: int = batch_size or int(
|
|
114
|
+
os.environ.get("SPANFORGE_SPLUNK_BATCH_SIZE", _DEFAULT_BATCH_SIZE)
|
|
115
|
+
)
|
|
116
|
+
self._timeout: float = timeout or float(
|
|
117
|
+
os.environ.get("SPANFORGE_SPLUNK_TIMEOUT", _DEFAULT_TIMEOUT)
|
|
118
|
+
)
|
|
119
|
+
self._verify_ssl: bool = verify_ssl
|
|
120
|
+
self._lock: threading.Lock = threading.Lock()
|
|
121
|
+
self._pending: list[dict[str, Any]] = []
|
|
122
|
+
self._sent_count: int = 0
|
|
123
|
+
self._error_count: int = 0
|
|
124
|
+
|
|
125
|
+
if not self._hec_url:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"Splunk HEC URL must be provided via hec_url argument or "
|
|
128
|
+
"SPANFORGE_SPLUNK_HEC_URL environment variable"
|
|
129
|
+
)
|
|
130
|
+
# Enforce HTTP/HTTPS-only — prevents file:// or custom-scheme injection (B310)
|
|
131
|
+
parsed_scheme = self._hec_url.split("://", 1)[0].lower() if "://" in self._hec_url else ""
|
|
132
|
+
if parsed_scheme not in ("http", "https"):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Splunk HEC URL must use http:// or https:// scheme, got: {self._hec_url!r}"
|
|
135
|
+
)
|
|
136
|
+
if not self._token:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"Splunk HEC token must be provided via token argument or "
|
|
139
|
+
"SPANFORGE_SPLUNK_HEC_TOKEN environment variable"
|
|
140
|
+
)
|
|
141
|
+
# Reject plaintext HTTP in non-test environments
|
|
142
|
+
if (
|
|
143
|
+
self._hec_url.startswith("http://")
|
|
144
|
+
and not self._hec_url.startswith("http://localhost")
|
|
145
|
+
and not self._hec_url.startswith("http://127.")
|
|
146
|
+
):
|
|
147
|
+
_log.warning(
|
|
148
|
+
"Splunk HEC URL uses plaintext HTTP — use HTTPS in production: %s",
|
|
149
|
+
self._hec_url,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Public API
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def export(self, event: Event) -> None:
|
|
157
|
+
"""Buffer *event* and flush when batch_size is reached."""
|
|
158
|
+
payload = self._build_hec_payload(event)
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._pending.append(payload)
|
|
161
|
+
if len(self._pending) >= self._batch_size:
|
|
162
|
+
self._flush_locked()
|
|
163
|
+
|
|
164
|
+
def export_batch(self, events: Sequence[Event]) -> int:
|
|
165
|
+
"""Export a batch of events. Returns the number of events sent."""
|
|
166
|
+
for event in events:
|
|
167
|
+
self.export(event)
|
|
168
|
+
self.flush()
|
|
169
|
+
return len(events)
|
|
170
|
+
|
|
171
|
+
def flush(self) -> None:
|
|
172
|
+
"""Force-flush any buffered events to Splunk HEC."""
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._flush_locked()
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
"""Flush and release resources."""
|
|
178
|
+
self.flush()
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def sent_count(self) -> int:
|
|
182
|
+
"""Total number of events successfully sent to Splunk."""
|
|
183
|
+
return self._sent_count
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def error_count(self) -> int:
|
|
187
|
+
"""Total number of delivery failures."""
|
|
188
|
+
return self._error_count
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Context manager support
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def __enter__(self) -> SplunkHECExporter:
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, *_: Any) -> None:
|
|
198
|
+
self.close()
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Internal helpers
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _build_hec_payload(self, event: Event) -> dict[str, Any]:
|
|
205
|
+
"""Convert a spanforge Event to a Splunk HEC event dict."""
|
|
206
|
+
return {
|
|
207
|
+
"time": event.timestamp if hasattr(event, "timestamp") else time.time(),
|
|
208
|
+
"index": self._index,
|
|
209
|
+
"source": self._source,
|
|
210
|
+
"sourcetype": self._sourcetype,
|
|
211
|
+
"event": event_to_siem_record(event),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def _flush_locked(self) -> None:
|
|
215
|
+
"""Send all pending payloads. Must be called with ``_lock`` held."""
|
|
216
|
+
if not self._pending:
|
|
217
|
+
return
|
|
218
|
+
batch = self._pending[:]
|
|
219
|
+
self._pending.clear()
|
|
220
|
+
try:
|
|
221
|
+
self._send(batch)
|
|
222
|
+
self._sent_count += len(batch)
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
self._error_count += len(batch)
|
|
225
|
+
_log.error("SplunkHECExporter: failed to send %d events — %s", len(batch), exc)
|
|
226
|
+
|
|
227
|
+
def _send(self, payloads: list[dict[str, Any]]) -> None:
|
|
228
|
+
"""POST *payloads* to the Splunk HEC endpoint.
|
|
229
|
+
|
|
230
|
+
Multiple events are encoded as newline-delimited JSON (Splunk's
|
|
231
|
+
raw HEC format).
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
SplunkHECError: On a permanent 4xx / 5xx response.
|
|
235
|
+
"""
|
|
236
|
+
body = "\n".join(json.dumps(p) for p in payloads).encode()
|
|
237
|
+
headers = {
|
|
238
|
+
"Authorization": f"Splunk {self._token}",
|
|
239
|
+
"Content-Type": "application/json",
|
|
240
|
+
}
|
|
241
|
+
req = urllib.request.Request(self._hec_url, data=body, headers=headers, method="POST")
|
|
242
|
+
ctx: ssl.SSLContext | None = None
|
|
243
|
+
if not self._verify_ssl:
|
|
244
|
+
ctx = ssl.create_default_context()
|
|
245
|
+
ctx.check_hostname = False
|
|
246
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
with urllib.request.urlopen(req, timeout=self._timeout, context=ctx) as resp: # nosec B310 — scheme validated to http/https in __init__
|
|
250
|
+
if resp.status >= 400:
|
|
251
|
+
raise SplunkHECError(
|
|
252
|
+
f"Splunk HEC returned HTTP {resp.status} for {len(payloads)} events"
|
|
253
|
+
)
|
|
254
|
+
except urllib.error.HTTPError as exc:
|
|
255
|
+
raise SplunkHECError(f"Splunk HEC HTTP error {exc.code}: {exc.reason}") from exc
|
|
256
|
+
except urllib.error.URLError as exc:
|
|
257
|
+
raise SplunkHECError(f"Splunk HEC connection error: {exc.reason}") from exc
|
|
258
|
+
|
|
259
|
+
def __repr__(self) -> str:
|
|
260
|
+
return (
|
|
261
|
+
f"SplunkHECExporter(hec_url={self._hec_url!r}, "
|
|
262
|
+
f"index={self._index!r}, "
|
|
263
|
+
f"sent={self._sent_count}, errors={self._error_count})"
|
|
264
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""spanforge.export.siem_syslog - Syslog / CEF exporter.
|
|
2
|
+
|
|
3
|
+
Forwards spanforge events to a remote syslog receiver (RFC 5424) optionally
|
|
4
|
+
encoded as ArcSight Common Event Format (CEF).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from spanforge.export.siem_schema import event_to_siem_record, severity_from_event
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from spanforge.event import Event
|
|
22
|
+
|
|
23
|
+
__all__ = ["SyslogExporter", "SyslogExporterError"]
|
|
24
|
+
|
|
25
|
+
_log = logging.getLogger("spanforge.export.siem_syslog")
|
|
26
|
+
|
|
27
|
+
_DEFAULT_PORT = 514
|
|
28
|
+
_DEFAULT_TRANSPORT = "udp"
|
|
29
|
+
_DEFAULT_FORMAT = "rfc5424"
|
|
30
|
+
_DEFAULT_APP_NAME = "spanforge"
|
|
31
|
+
_DEFAULT_FACILITY = 16 # local0
|
|
32
|
+
_SEVERITY_MAP: dict[str, int] = {
|
|
33
|
+
"alert": 1,
|
|
34
|
+
"error": 3,
|
|
35
|
+
"warn": 4,
|
|
36
|
+
"warning": 4,
|
|
37
|
+
"info": 6,
|
|
38
|
+
"debug": 7,
|
|
39
|
+
"trace": 7,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_CEF_VENDOR = "SpanForge"
|
|
43
|
+
_CEF_PRODUCT = "SpanForge"
|
|
44
|
+
_CEF_VERSION = "1.0"
|
|
45
|
+
_CEF_ESCAPE_RE = re.compile(r"([\\|=])")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SyslogExporterError(RuntimeError):
|
|
49
|
+
"""Raised when a syslog delivery attempt fails permanently."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SyslogExporter:
|
|
53
|
+
"""Export spanforge events to a remote syslog receiver."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
host: str = "",
|
|
59
|
+
port: int = 0,
|
|
60
|
+
transport: str = "",
|
|
61
|
+
format: str = "",
|
|
62
|
+
app_name: str = "",
|
|
63
|
+
facility: int = -1,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._host: str = host or os.environ.get("SPANFORGE_SYSLOG_HOST", "")
|
|
66
|
+
self._port: int = port or int(os.environ.get("SPANFORGE_SYSLOG_PORT", _DEFAULT_PORT))
|
|
67
|
+
self._transport: str = (
|
|
68
|
+
transport or os.environ.get("SPANFORGE_SYSLOG_TRANSPORT", _DEFAULT_TRANSPORT)
|
|
69
|
+
).lower()
|
|
70
|
+
self._format: str = (
|
|
71
|
+
format or os.environ.get("SPANFORGE_SYSLOG_FORMAT", _DEFAULT_FORMAT)
|
|
72
|
+
).lower()
|
|
73
|
+
self._app_name: str = app_name or os.environ.get(
|
|
74
|
+
"SPANFORGE_SYSLOG_APP_NAME", _DEFAULT_APP_NAME
|
|
75
|
+
)
|
|
76
|
+
self._facility: int = (
|
|
77
|
+
facility
|
|
78
|
+
if facility >= 0
|
|
79
|
+
else int(os.environ.get("SPANFORGE_SYSLOG_FACILITY", _DEFAULT_FACILITY))
|
|
80
|
+
)
|
|
81
|
+
self._lock: threading.Lock = threading.Lock()
|
|
82
|
+
self._sent_count: int = 0
|
|
83
|
+
self._error_count: int = 0
|
|
84
|
+
|
|
85
|
+
if not self._host:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Syslog host must be provided via host argument or "
|
|
88
|
+
"SPANFORGE_SYSLOG_HOST environment variable"
|
|
89
|
+
)
|
|
90
|
+
if self._transport not in ("udp", "tcp"):
|
|
91
|
+
raise ValueError(f"transport must be 'udp' or 'tcp', got {self._transport!r}")
|
|
92
|
+
if self._format not in ("rfc5424", "cef"):
|
|
93
|
+
raise ValueError(f"format must be 'rfc5424' or 'cef', got {self._format!r}")
|
|
94
|
+
if not (0 <= self._facility <= 23):
|
|
95
|
+
raise ValueError(f"facility must be in range 0-23, got {self._facility}")
|
|
96
|
+
|
|
97
|
+
def export(self, event: Event) -> None:
|
|
98
|
+
"""Encode *event* and send it to the syslog receiver."""
|
|
99
|
+
message = self._format_cef(event) if self._format == "cef" else self._format_rfc5424(event)
|
|
100
|
+
try:
|
|
101
|
+
self._send(message)
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._sent_count += 1
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._error_count += 1
|
|
107
|
+
_log.error("SyslogExporter: failed to send event - %s", exc)
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""No persistent connection; this is a no-op for UDP mode."""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def sent_count(self) -> int:
|
|
114
|
+
"""Total events successfully delivered."""
|
|
115
|
+
return self._sent_count
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def error_count(self) -> int:
|
|
119
|
+
"""Total delivery failures."""
|
|
120
|
+
return self._error_count
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> SyslogExporter:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *_: Any) -> None:
|
|
126
|
+
self.close()
|
|
127
|
+
|
|
128
|
+
def _severity_from_event(self, event: Event) -> int:
|
|
129
|
+
"""Map event_type prefix to a syslog severity (0-7)."""
|
|
130
|
+
return severity_from_event(event)
|
|
131
|
+
|
|
132
|
+
def _priority(self, severity: int) -> int:
|
|
133
|
+
"""Compute syslog PRI value from facility and severity."""
|
|
134
|
+
return self._facility * 8 + severity
|
|
135
|
+
|
|
136
|
+
def _format_rfc5424(self, event: Event) -> str:
|
|
137
|
+
"""Format event as an RFC 5424 syslog message."""
|
|
138
|
+
severity = self._severity_from_event(event)
|
|
139
|
+
pri = self._priority(severity)
|
|
140
|
+
timestamp = (
|
|
141
|
+
datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
142
|
+
)
|
|
143
|
+
hostname = socket.gethostname()
|
|
144
|
+
proc_id = "-"
|
|
145
|
+
msg_id = event.event_type.replace(" ", "_")
|
|
146
|
+
structured_data = "-"
|
|
147
|
+
record_json = json.dumps(event_to_siem_record(event), sort_keys=True)
|
|
148
|
+
msg = f"spanforge event_id={event.event_id} payload={record_json}"
|
|
149
|
+
return (
|
|
150
|
+
f"<{pri}>1 {timestamp} {hostname} {self._app_name} "
|
|
151
|
+
f"{proc_id} {msg_id} {structured_data} {msg}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _format_cef(self, event: Event) -> str:
|
|
155
|
+
"""Format event as a CEF (ArcSight Common Event Format) message."""
|
|
156
|
+
severity = self._severity_from_event(event)
|
|
157
|
+
pri = self._priority(severity)
|
|
158
|
+
timestamp = (
|
|
159
|
+
datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
160
|
+
)
|
|
161
|
+
hostname = socket.gethostname()
|
|
162
|
+
event_type_escaped = _CEF_ESCAPE_RE.sub(r"\\\1", event.event_type)
|
|
163
|
+
cef_header = (
|
|
164
|
+
f"CEF:0|{_CEF_VENDOR}|{_CEF_PRODUCT}|{_CEF_VERSION}|"
|
|
165
|
+
f"{event_type_escaped}|{event_type_escaped}|{severity}|"
|
|
166
|
+
)
|
|
167
|
+
extensions: dict[str, str] = {
|
|
168
|
+
"rt": timestamp,
|
|
169
|
+
"deviceExternalId": event.event_id,
|
|
170
|
+
"app": self._app_name,
|
|
171
|
+
"event_type": event.event_type,
|
|
172
|
+
}
|
|
173
|
+
siem_record = event_to_siem_record(event)
|
|
174
|
+
for key, value in siem_record.items():
|
|
175
|
+
if key == "payload":
|
|
176
|
+
continue
|
|
177
|
+
safe_key = re.sub(r"[^A-Za-z0-9_]", "_", str(key))
|
|
178
|
+
safe_value = (
|
|
179
|
+
json.dumps(value, sort_keys=True) if isinstance(value, (dict, list)) else str(value)
|
|
180
|
+
)
|
|
181
|
+
extensions[safe_key] = _CEF_ESCAPE_RE.sub(r"\\\1", safe_value)
|
|
182
|
+
payload = getattr(event, "payload", {}) or {}
|
|
183
|
+
if isinstance(payload, dict):
|
|
184
|
+
for key, value in payload.items():
|
|
185
|
+
safe_key = re.sub(r"[^A-Za-z0-9_]", "_", str(key))
|
|
186
|
+
safe_value = _CEF_ESCAPE_RE.sub(r"\\\1", str(value))
|
|
187
|
+
extensions[safe_key] = safe_value
|
|
188
|
+
ext_str = " ".join(f"{key}={value}" for key, value in extensions.items())
|
|
189
|
+
syslog_prefix = f"<{pri}>1 {timestamp} {hostname} {self._app_name} - - - "
|
|
190
|
+
return syslog_prefix + cef_header + ext_str
|
|
191
|
+
|
|
192
|
+
def _send(self, message: str) -> None:
|
|
193
|
+
"""Deliver *message* via UDP or TCP syslog."""
|
|
194
|
+
data = (message + "\n").encode("utf-8", errors="replace")
|
|
195
|
+
try:
|
|
196
|
+
if self._transport == "udp":
|
|
197
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
198
|
+
sock.sendto(data, (self._host, self._port))
|
|
199
|
+
else:
|
|
200
|
+
with socket.create_connection((self._host, self._port), timeout=5.0) as sock:
|
|
201
|
+
sock.sendall(data)
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise SyslogExporterError(
|
|
204
|
+
f"Syslog delivery failed to {self._host}:{self._port} ({self._transport}): {exc}"
|
|
205
|
+
) from exc
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str:
|
|
208
|
+
return (
|
|
209
|
+
f"SyslogExporter(host={self._host!r}, port={self._port}, "
|
|
210
|
+
f"transport={self._transport!r}, format={self._format!r}, "
|
|
211
|
+
f"sent={self._sent_count}, errors={self._error_count})"
|
|
212
|
+
)
|