atherdlp 0.0.2__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.
- atherdlp/__init__.py +34 -0
- atherdlp/classifier/__init__.py +4 -0
- atherdlp/classifier/classify.py +13 -0
- atherdlp/classifier/providers.py +18 -0
- atherdlp/config.py +40 -0
- atherdlp/context/__init__.py +3 -0
- atherdlp/context/trace.py +28 -0
- atherdlp/interceptor/__init__.py +4 -0
- atherdlp/interceptor/base.py +18 -0
- atherdlp/interceptor/httpx_hook.py +234 -0
- atherdlp/limits.py +33 -0
- atherdlp/py.typed +0 -0
- atherdlp/transport/__init__.py +3 -0
- atherdlp/transport/sender.py +119 -0
- atherdlp-0.0.2.dist-info/METADATA +79 -0
- atherdlp-0.0.2.dist-info/RECORD +18 -0
- atherdlp-0.0.2.dist-info/WHEEL +4 -0
- atherdlp-0.0.2.dist-info/licenses/LICENSE +21 -0
atherdlp/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from atherdlp.config import configure, get_config
|
|
6
|
+
from atherdlp.interceptor.httpx_hook import install_httpx, uninstall_httpx
|
|
7
|
+
|
|
8
|
+
_handler: Callable[[dict], None] | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_handler(handler: Callable[[dict], None] | None) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Set the callback that receives intercepted payloads.
|
|
14
|
+
|
|
15
|
+
The handler must be fail-safe; exceptions are swallowed by the interceptor.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
global _handler
|
|
19
|
+
_handler = handler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_handler() -> Callable[[dict], None]:
|
|
23
|
+
h = _handler
|
|
24
|
+
if h is None:
|
|
25
|
+
return lambda _payload: None
|
|
26
|
+
return h
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install() -> None:
|
|
30
|
+
install_httpx(_get_handler())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def uninstall() -> None:
|
|
34
|
+
uninstall_httpx()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from atherdlp.classifier.providers import PROVIDERS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def classify_destination(url: str) -> dict:
|
|
9
|
+
host = (urlparse(url).hostname or "").lower()
|
|
10
|
+
meta = PROVIDERS.get(host)
|
|
11
|
+
if meta is None:
|
|
12
|
+
return {"kind": "unknown", "vendor": None}
|
|
13
|
+
return dict(meta)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PROVIDERS: dict[str, dict] = {
|
|
7
|
+
"api.openai.com": {"kind": "llm", "vendor": "openai"},
|
|
8
|
+
"api.anthropic.com": {"kind": "llm", "vendor": "anthropic"},
|
|
9
|
+
"generativelanguage.googleapis.com": {"kind": "llm", "vendor": "google"},
|
|
10
|
+
"api.cohere.ai": {"kind": "llm", "vendor": "cohere"},
|
|
11
|
+
"api.mistral.ai": {"kind": "llm", "vendor": "mistral"},
|
|
12
|
+
"api.stripe.com": {"kind": "http.out", "vendor": "stripe", "sensitivity": "pci"},
|
|
13
|
+
"api.github.com": {"kind": "http.out", "vendor": "github"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_provider(host: str, meta: Mapping) -> None:
|
|
18
|
+
PROVIDERS[host.lower()] = dict(meta)
|
atherdlp/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class SDKConfig:
|
|
8
|
+
endpoint: str | None = None
|
|
9
|
+
api_key: str | None = None
|
|
10
|
+
timeout_s: float = 0.2
|
|
11
|
+
enabled: bool = True
|
|
12
|
+
# Max backend emit tasks accepted at once (queued in the executor + running).
|
|
13
|
+
# When saturated, new emits are dropped (fail-open). Use 0 for no limit (not recommended under load).
|
|
14
|
+
max_pending_backend_emits: int = 128
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_config = SDKConfig()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure(
|
|
21
|
+
*,
|
|
22
|
+
endpoint: str | None = None,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
timeout_s: float = 0.2,
|
|
25
|
+
enabled: bool = True,
|
|
26
|
+
max_pending_backend_emits: int = 128,
|
|
27
|
+
) -> None:
|
|
28
|
+
global _config
|
|
29
|
+
_config = SDKConfig(
|
|
30
|
+
endpoint=endpoint,
|
|
31
|
+
api_key=api_key,
|
|
32
|
+
timeout_s=timeout_s,
|
|
33
|
+
enabled=enabled,
|
|
34
|
+
max_pending_backend_emits=max_pending_backend_emits,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_config() -> SDKConfig:
|
|
39
|
+
return _config
|
|
40
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TraceContext:
|
|
9
|
+
trace_id: str
|
|
10
|
+
span_id: str
|
|
11
|
+
parent_span_id: str | None = None
|
|
12
|
+
|
|
13
|
+
def to_dict(self) -> dict:
|
|
14
|
+
return asdict(self)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_trace_id() -> str:
|
|
18
|
+
# 32 hex chars (W3C trace-id compatible shape)
|
|
19
|
+
return secrets.token_hex(16)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def new_span_id() -> str:
|
|
23
|
+
# 16 hex chars (W3C span-id compatible shape)
|
|
24
|
+
return secrets.token_hex(8)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_trace(*, parent_span_id: str | None = None) -> TraceContext:
|
|
28
|
+
return TraceContext(trace_id=new_trace_id(), span_id=new_span_id(), parent_span_id=parent_span_id)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PayloadHandler(Protocol):
|
|
8
|
+
def __call__(self, payload: dict) -> None: ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class InterceptedPayload:
|
|
13
|
+
request: dict
|
|
14
|
+
classification: dict
|
|
15
|
+
trace: dict
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict:
|
|
18
|
+
return asdict(self)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from atherdlp.classifier.classify import classify_destination
|
|
11
|
+
from atherdlp.context.trace import new_trace
|
|
12
|
+
from atherdlp.config import get_config
|
|
13
|
+
from atherdlp.interceptor.base import PayloadHandler
|
|
14
|
+
from atherdlp.limits import get_sdk_interceptor_bytes_limits
|
|
15
|
+
from atherdlp.transport.sender import send_to_backend
|
|
16
|
+
|
|
17
|
+
from email.parser import BytesParser
|
|
18
|
+
from email.policy import default
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _State:
|
|
22
|
+
installed: bool = False
|
|
23
|
+
handler: PayloadHandler | None = None
|
|
24
|
+
orig_client_init: Any | None = None
|
|
25
|
+
orig_async_client_init: Any | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_state = _State()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def install_httpx(handler: PayloadHandler) -> None:
|
|
32
|
+
if _state.installed:
|
|
33
|
+
_state.handler = handler
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
_state.handler = handler
|
|
37
|
+
_state.orig_client_init = httpx.Client.__init__
|
|
38
|
+
_state.orig_async_client_init = httpx.AsyncClient.__init__
|
|
39
|
+
|
|
40
|
+
def _client_init(self: httpx.Client, *args: Any, **kwargs: Any) -> None:
|
|
41
|
+
assert _state.orig_client_init is not None
|
|
42
|
+
_state.orig_client_init(self, *args, **kwargs)
|
|
43
|
+
if getattr(self, "_transport", None) is not None:
|
|
44
|
+
self._transport = _AtherTransport(self._transport)
|
|
45
|
+
if getattr(self, "_mounts", None):
|
|
46
|
+
self._mounts = {k: _AtherTransport(v) for k, v in self._mounts.items()}
|
|
47
|
+
|
|
48
|
+
def _async_client_init(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> None:
|
|
49
|
+
assert _state.orig_async_client_init is not None
|
|
50
|
+
_state.orig_async_client_init(self, *args, **kwargs)
|
|
51
|
+
if getattr(self, "_transport", None) is not None:
|
|
52
|
+
self._transport = _AtherAsyncTransport(self._transport)
|
|
53
|
+
if getattr(self, "_mounts", None):
|
|
54
|
+
self._mounts = {k: _AtherAsyncTransport(v) for k, v in self._mounts.items()}
|
|
55
|
+
|
|
56
|
+
httpx.Client.__init__ = _client_init # type: ignore[assignment]
|
|
57
|
+
httpx.AsyncClient.__init__ = _async_client_init # type: ignore[assignment]
|
|
58
|
+
_state.installed = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uninstall_httpx() -> None:
|
|
62
|
+
global _state
|
|
63
|
+
if not _state.installed:
|
|
64
|
+
return
|
|
65
|
+
if _state.orig_client_init is not None:
|
|
66
|
+
httpx.Client.__init__ = _state.orig_client_init # type: ignore[assignment]
|
|
67
|
+
if _state.orig_async_client_init is not None:
|
|
68
|
+
httpx.AsyncClient.__init__ = _state.orig_async_client_init # type: ignore[assignment]
|
|
69
|
+
_state = _State() # reset
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _AtherTransport(httpx.BaseTransport):
|
|
73
|
+
def __init__(self, inner: httpx.BaseTransport):
|
|
74
|
+
self._inner = inner
|
|
75
|
+
|
|
76
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
77
|
+
_emit(request)
|
|
78
|
+
return self._inner.handle_request(request)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _AtherAsyncTransport(httpx.AsyncBaseTransport):
|
|
82
|
+
def __init__(self, inner: httpx.AsyncBaseTransport):
|
|
83
|
+
self._inner = inner
|
|
84
|
+
|
|
85
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
86
|
+
_emit(request)
|
|
87
|
+
return await self._inner.handle_async_request(request)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _emit(request: httpx.Request) -> None:
|
|
91
|
+
# Never intercept SDK-internal backend calls (prevents recursion).
|
|
92
|
+
if request.headers.get("x-ather-internal") == "1":
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Also skip interception when the destination is the configured backend endpoint.
|
|
96
|
+
cfg = get_config()
|
|
97
|
+
if cfg.endpoint:
|
|
98
|
+
try:
|
|
99
|
+
if str(request.url) == str(cfg.endpoint):
|
|
100
|
+
return
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
trace = new_trace().to_dict()
|
|
105
|
+
# Propagate trace to the upstream destination (best-effort).
|
|
106
|
+
try:
|
|
107
|
+
request.headers["x-ather-trace-id"] = trace["trace_id"]
|
|
108
|
+
request.headers["x-ather-span-id"] = trace["span_id"]
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
classification = classify_destination(str(request.url))
|
|
113
|
+
|
|
114
|
+
payload = {
|
|
115
|
+
"request": {
|
|
116
|
+
"method": request.method,
|
|
117
|
+
"url": str(request.url),
|
|
118
|
+
"headers": dict(request.headers),
|
|
119
|
+
"body": _safe_body(request),
|
|
120
|
+
},
|
|
121
|
+
"classification": classification,
|
|
122
|
+
"trace": trace,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Best-effort delivery to backend (non-blocking, fail-open).
|
|
126
|
+
send_to_backend(payload)
|
|
127
|
+
|
|
128
|
+
handler = _state.handler
|
|
129
|
+
if handler is None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
handler(payload)
|
|
133
|
+
except Exception:
|
|
134
|
+
# fail-open: never break the host app
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _safe_body(request: httpx.Request) -> Any:
|
|
139
|
+
try:
|
|
140
|
+
raw = request.content or b""
|
|
141
|
+
except httpx.RequestNotRead:
|
|
142
|
+
# Multipart/form uploads are streamed by default. Reading here should
|
|
143
|
+
# materialize the bytes and keep request semantics intact.
|
|
144
|
+
raw = request.read() or b""
|
|
145
|
+
if not raw:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
ctype = request.headers.get("content-type", "")
|
|
149
|
+
|
|
150
|
+
# MULTIPART (files/form fields)
|
|
151
|
+
if "multipart/form-data" in ctype:
|
|
152
|
+
return _extract_multipart(raw, ctype)
|
|
153
|
+
|
|
154
|
+
max_non, _, sample_b = get_sdk_interceptor_bytes_limits()
|
|
155
|
+
# LARGE BODY → SAMPLE
|
|
156
|
+
if len(raw) > max_non:
|
|
157
|
+
return _sample_bytes(raw, size=len(raw), sample_slice_bytes=sample_b)
|
|
158
|
+
|
|
159
|
+
# JSON
|
|
160
|
+
if "application/json" in ctype:
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(raw)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# TEXT
|
|
167
|
+
try:
|
|
168
|
+
return raw.decode("utf-8")
|
|
169
|
+
except Exception:
|
|
170
|
+
return {"_binary": True, "size": len(raw)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sample_bytes(raw: bytes, *, size: int, sample_slice_bytes: int) -> dict:
|
|
176
|
+
sb = max(1, sample_slice_bytes)
|
|
177
|
+
start = raw[:sb]
|
|
178
|
+
mid_start = max(0, size // 2 - sb // 2)
|
|
179
|
+
middle = raw[mid_start : mid_start + sb]
|
|
180
|
+
end = raw[-sb:] if size >= sb else raw
|
|
181
|
+
|
|
182
|
+
def _to_text(chunk: bytes) -> str:
|
|
183
|
+
try:
|
|
184
|
+
return chunk.decode("utf-8", errors="replace")
|
|
185
|
+
except Exception:
|
|
186
|
+
return base64.b64encode(chunk).decode("ascii")
|
|
187
|
+
|
|
188
|
+
return {"_sampled": True, "size": size, "samples": [_to_text(start), _to_text(middle), _to_text(end)]}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_multipart(raw: bytes, content_type: str):
|
|
192
|
+
_, max_inline, sample_b = get_sdk_interceptor_bytes_limits()
|
|
193
|
+
msg = BytesParser(policy=default).parsebytes(
|
|
194
|
+
b"Content-Type: " + content_type.encode() + b"\n\n" + raw
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
files = []
|
|
198
|
+
fields = []
|
|
199
|
+
|
|
200
|
+
for part in msg.iter_parts():
|
|
201
|
+
content = part.get_payload(decode=True) or b""
|
|
202
|
+
# get the filename from the part If this exists → it's a file
|
|
203
|
+
filename = part.get_filename()
|
|
204
|
+
|
|
205
|
+
if filename:
|
|
206
|
+
size = len(content)
|
|
207
|
+
if size > max_inline:
|
|
208
|
+
sampled: Any = _sample_bytes(
|
|
209
|
+
content, size=size, sample_slice_bytes=sample_b
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
sampled = content.decode("utf-8", errors="replace")
|
|
214
|
+
except Exception:
|
|
215
|
+
sampled = {"_binary": True, "size": size}
|
|
216
|
+
|
|
217
|
+
files.append({
|
|
218
|
+
"filename": filename,
|
|
219
|
+
"content": sampled,
|
|
220
|
+
"size": size,
|
|
221
|
+
"content_type": part.get_content_type(),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
else:
|
|
225
|
+
fields.append({
|
|
226
|
+
"name": part.get_param("name", header="content-disposition"),
|
|
227
|
+
"value": content.decode("utf-8", errors="replace") if content else None
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"files": files,
|
|
232
|
+
"fields": fields
|
|
233
|
+
}
|
|
234
|
+
|
atherdlp/limits.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
# Default values must match AtherDLP-infra/.env.example (canonical source in repo).
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _int_env(name: str, default: int) -> int:
|
|
10
|
+
raw = os.environ.get(name)
|
|
11
|
+
if raw is None or str(raw).strip() == "":
|
|
12
|
+
return default
|
|
13
|
+
try:
|
|
14
|
+
return int(str(raw).strip(), 10)
|
|
15
|
+
except (TypeError, ValueError):
|
|
16
|
+
return default
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=1)
|
|
20
|
+
def get_sdk_interceptor_bytes_limits() -> tuple[int, int, int]:
|
|
21
|
+
"""
|
|
22
|
+
(max_non_multipart, max_file_inline, sample_slice).
|
|
23
|
+
|
|
24
|
+
Set in process env, typically from AtherDLP-infra/.env:
|
|
25
|
+
ATHERDLP_SDK_MAX_NON_MULTIPART_BODY_BYTES
|
|
26
|
+
ATHERDLP_SDK_MAX_FILE_INLINE_BYTES
|
|
27
|
+
ATHERDLP_SDK_SAMPLE_SLICE_BYTES
|
|
28
|
+
"""
|
|
29
|
+
return (
|
|
30
|
+
_int_env("ATHERDLP_SDK_MAX_NON_MULTIPART_BODY_BYTES", 2 * 1024 * 1024),
|
|
31
|
+
_int_env("ATHERDLP_SDK_MAX_FILE_INLINE_BYTES", 256 * 1024),
|
|
32
|
+
_int_env("ATHERDLP_SDK_SAMPLE_SLICE_BYTES", 16 * 1024),
|
|
33
|
+
)
|
atherdlp/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from atherdlp.config import get_config
|
|
13
|
+
|
|
14
|
+
_log = logging.getLogger("atherdlp")
|
|
15
|
+
|
|
16
|
+
# Match backend correlation rules (shared.correlation.SAFE_CID): alnum + hyphen, max 64.
|
|
17
|
+
_SAFE_CORRELATION_ID = re.compile(r"^[a-zA-Z0-9\-]{1,64}$")
|
|
18
|
+
|
|
19
|
+
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="atherdlp")
|
|
20
|
+
_emit_lock = threading.Lock()
|
|
21
|
+
_pending_backend_emits = 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _first_request_header(hdrs: Any, canon_name_lc: str) -> str | None:
|
|
25
|
+
"""Case-insensitive first value from payload snapshot headers."""
|
|
26
|
+
if not isinstance(hdrs, dict):
|
|
27
|
+
return None
|
|
28
|
+
for k, val in hdrs.items():
|
|
29
|
+
if str(k).lower() != canon_name_lc:
|
|
30
|
+
continue
|
|
31
|
+
if val is None:
|
|
32
|
+
continue
|
|
33
|
+
s = str(val).strip()
|
|
34
|
+
if s:
|
|
35
|
+
return s
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _correlation_id_for_backend_header(payload: dict[str, Any], trace_id: Any) -> str | None:
|
|
40
|
+
incoming = _first_request_header(
|
|
41
|
+
(payload.get("request") or {}).get("headers"),
|
|
42
|
+
"x-correlation-id",
|
|
43
|
+
)
|
|
44
|
+
if incoming and _SAFE_CORRELATION_ID.match(incoming):
|
|
45
|
+
return incoming
|
|
46
|
+
tid = str(trace_id).strip() if trace_id is not None and str(trace_id).strip() else None
|
|
47
|
+
if tid and _SAFE_CORRELATION_ID.match(tid):
|
|
48
|
+
return tid
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def send_to_backend(payload: dict[str, Any]) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Best-effort, fail-open delivery of intercepted payloads.
|
|
55
|
+
Non-blocking by design: dispatches in a thread and returns immediately.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
cfg = get_config()
|
|
59
|
+
if not cfg.enabled or not cfg.endpoint:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
trace = payload.get("trace") or {}
|
|
63
|
+
trace_id = trace.get("trace_id")
|
|
64
|
+
span_id = trace.get("span_id")
|
|
65
|
+
|
|
66
|
+
headers: dict[str, str] = {"content-type": "application/json"}
|
|
67
|
+
# Prevent the SDK from intercepting its own backend calls.
|
|
68
|
+
headers["x-ather-internal"] = "1"
|
|
69
|
+
if cfg.api_key:
|
|
70
|
+
# Authentication to the backend
|
|
71
|
+
headers["x-api-key"] = cfg.api_key
|
|
72
|
+
if trace_id:
|
|
73
|
+
headers["x-ather-trace-id"] = str(trace_id)
|
|
74
|
+
cid = _correlation_id_for_backend_header(payload, trace_id)
|
|
75
|
+
if cid:
|
|
76
|
+
headers["X-Correlation-Id"] = cid
|
|
77
|
+
if span_id:
|
|
78
|
+
headers["x-ather-span-id"] = str(span_id)
|
|
79
|
+
|
|
80
|
+
limit = cfg.max_pending_backend_emits
|
|
81
|
+
|
|
82
|
+
global _pending_backend_emits
|
|
83
|
+
|
|
84
|
+
def _release_pending() -> None:
|
|
85
|
+
global _pending_backend_emits
|
|
86
|
+
if limit <= 0:
|
|
87
|
+
return
|
|
88
|
+
with _emit_lock:
|
|
89
|
+
_pending_backend_emits -= 1
|
|
90
|
+
|
|
91
|
+
if limit > 0:
|
|
92
|
+
with _emit_lock:
|
|
93
|
+
if _pending_backend_emits >= limit:
|
|
94
|
+
_log.debug(
|
|
95
|
+
"backend emit backlog saturated; dropping (%d/%d pending)",
|
|
96
|
+
_pending_backend_emits,
|
|
97
|
+
limit,
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
_pending_backend_emits += 1
|
|
101
|
+
|
|
102
|
+
def _do_send() -> None:
|
|
103
|
+
try:
|
|
104
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
105
|
+
# Create a short-lived client per send to avoid shared state issues.
|
|
106
|
+
with httpx.Client(timeout=cfg.timeout_s) as client:
|
|
107
|
+
client.post(cfg.endpoint, content=body, headers=headers)
|
|
108
|
+
except Exception as e: # fail-open
|
|
109
|
+
_log.debug("backend emit failed: %s", e)
|
|
110
|
+
finally:
|
|
111
|
+
_release_pending()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
_executor.submit(_do_send)
|
|
115
|
+
except Exception as e: # fail-open
|
|
116
|
+
if limit > 0:
|
|
117
|
+
with _emit_lock:
|
|
118
|
+
_pending_backend_emits -= 1
|
|
119
|
+
_log.debug("backend emit submit failed: %s", e)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: atherdlp
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Developer-first DLP HTTP interceptor (Stage 1)
|
|
5
|
+
Project-URL: Homepage, https://github.com/AetherDLP/AtherDLP-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/AetherDLP/AtherDLP-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/AetherDLP/AtherDLP-sdk/issues
|
|
8
|
+
Author-email: Subham Singh Chauhan <subhamchauhan1100@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dlp,httpx,interceptor,observability,security,tracing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# AtherDLP (Python) — Stage 1 HTTP Interceptor
|
|
32
|
+
|
|
33
|
+
Stage 1 intercepts outbound HTTP requests, classifies the destination (O(1) registry lookup), attaches a trace context, and emits a structured payload to a handler.
|
|
34
|
+
|
|
35
|
+
## Install (dev)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd AtherDLP-sdk/python
|
|
39
|
+
python -m pip install -e ".[test]"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import httpx
|
|
46
|
+
import atherdlp
|
|
47
|
+
|
|
48
|
+
def handler(payload: dict) -> None:
|
|
49
|
+
print(payload)
|
|
50
|
+
|
|
51
|
+
atherdlp.set_handler(handler)
|
|
52
|
+
atherdlp.install()
|
|
53
|
+
|
|
54
|
+
httpx.get("https://api.openai.com/v1/models", headers={"authorization": "Bearer x"})
|
|
55
|
+
|
|
56
|
+
atherdlp.uninstall()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Tracing
|
|
60
|
+
|
|
61
|
+
- If the request includes a **correlation header** (e.g. **`X-Correlation-Id`**), it is **propagated automatically** (safe values only: **ASCII alphanumeric + `-`, 1–64 chars**).
|
|
62
|
+
- If **not** present or invalid, AtherDLP **generates its own** trace id (`trace.trace_id` in the payload).
|
|
63
|
+
- **Ather-specific** tracing is **always** added on the outbound call via **`x-ather-trace-id`** (and **`x-ather-span-id`**).
|
|
64
|
+
|
|
65
|
+
More detail: **`docs/SDK.md`** → *Trace And Recursion Guards*.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
httpx.post(url, headers={"X-Correlation-Id": "my-route-8842"})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Output payload
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"request": { "method": "...", "url": "...", "headers": { }, "body": null },
|
|
76
|
+
"classification": { "kind": "llm|http.out|unknown", "vendor": "...", "sensitivity": "..." },
|
|
77
|
+
"trace": { "trace_id": "...", "span_id": "...", "parent_span_id": null }
|
|
78
|
+
}
|
|
79
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
atherdlp/__init__.py,sha256=vZ8wBjMIwrCgfQfDncEWPrYn0o3yEmHzWkSUoSakhRU,743
|
|
2
|
+
atherdlp/config.py,sha256=Mm5WaqQxzhokRAvyZzwVuxkosCfDD4XKqiw8mmC91gU,947
|
|
3
|
+
atherdlp/limits.py,sha256=Gw8NGPkXceqvBmfP6F-AR0hOK-AWyNzGfoMP0dFlM84,993
|
|
4
|
+
atherdlp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
atherdlp/classifier/__init__.py,sha256=MmvbR1N0_tJ0-6ITzMm45oCkz2-_vPVJzHLqcppn-N8,202
|
|
6
|
+
atherdlp/classifier/classify.py,sha256=W1KmMZW7eQp1PyZG1JqUo2v78AIPgMMBhRK97UsxWWY,343
|
|
7
|
+
atherdlp/classifier/providers.py,sha256=FNM8M8cgS2PMo6NPkLuZaMAxH5Hznw2C82nBVDGLJyk,667
|
|
8
|
+
atherdlp/context/__init__.py,sha256=st34zr4liU0aWaSEwbjRQ8h-EfU26m_JC_BYv2vTWLY,157
|
|
9
|
+
atherdlp/context/trace.py,sha256=YnUVCt5ndwMoS9n1iKYrzxkBaMqngGDieXtnKdaqg5Y,668
|
|
10
|
+
atherdlp/interceptor/__init__.py,sha256=AUxn7kp21861b4cfC1Ay8indw1W-fHQ_smSP05XNI3k,235
|
|
11
|
+
atherdlp/interceptor/base.py,sha256=vylocgDo5UcPaUJUcAVqORHdh0v96J_M8lS0A26fspA,361
|
|
12
|
+
atherdlp/interceptor/httpx_hook.py,sha256=2U7DxVxpKfSSuZ-hMdaPRxghB704S2m7cbcJGWqAzoQ,7370
|
|
13
|
+
atherdlp/transport/__init__.py,sha256=4zN3dU68u8Hcp4Hs__uFhsW-lL4hjXjKl0oN-l3l0co,85
|
|
14
|
+
atherdlp/transport/sender.py,sha256=KEu60U34osOBEJZELsmE5CoOv2adWWdgaNiOmxrbuaE,3698
|
|
15
|
+
atherdlp-0.0.2.dist-info/METADATA,sha256=9k9VbyEQocdmTVipYF9uQQHbQmqf0oxtjBPFDTdn3yo,2651
|
|
16
|
+
atherdlp-0.0.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
17
|
+
atherdlp-0.0.2.dist-info/licenses/LICENSE,sha256=0GA5dQkfotO0XJyS5wCeOkZmFEzBQIesrR4yUDRm658,1077
|
|
18
|
+
atherdlp-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Subham Singh Chauhan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|