atherdlp 0.0.2__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ # Python caches / artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ coverage.xml
9
+ htmlcov/
10
+
11
+ # Virtualenvs
12
+ .venv/
13
+ venv/
14
+ ENV/
15
+ env/
16
+
17
+ # Local env/secrets
18
+ .env
19
+ .env.*
20
+ !.env.example
21
+
22
+ # Logs
23
+ *.log
24
+
25
+ # Build output
26
+ dist/
27
+ build/
28
+ *.egg-info/
29
+
30
+ # OS
31
+ .DS_Store
atherdlp-0.0.2/LICENSE ADDED
@@ -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.
@@ -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,49 @@
1
+ # AtherDLP (Python) — Stage 1 HTTP Interceptor
2
+
3
+ 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.
4
+
5
+ ## Install (dev)
6
+
7
+ ```bash
8
+ cd AtherDLP-sdk/python
9
+ python -m pip install -e ".[test]"
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ import httpx
16
+ import atherdlp
17
+
18
+ def handler(payload: dict) -> None:
19
+ print(payload)
20
+
21
+ atherdlp.set_handler(handler)
22
+ atherdlp.install()
23
+
24
+ httpx.get("https://api.openai.com/v1/models", headers={"authorization": "Bearer x"})
25
+
26
+ atherdlp.uninstall()
27
+ ```
28
+
29
+ ### Tracing
30
+
31
+ - If the request includes a **correlation header** (e.g. **`X-Correlation-Id`**), it is **propagated automatically** (safe values only: **ASCII alphanumeric + `-`, 1–64 chars**).
32
+ - If **not** present or invalid, AtherDLP **generates its own** trace id (`trace.trace_id` in the payload).
33
+ - **Ather-specific** tracing is **always** added on the outbound call via **`x-ather-trace-id`** (and **`x-ather-span-id`**).
34
+
35
+ More detail: **`docs/SDK.md`** → *Trace And Recursion Guards*.
36
+
37
+ ```python
38
+ httpx.post(url, headers={"X-Correlation-Id": "my-route-8842"})
39
+ ```
40
+
41
+ ## Output payload
42
+
43
+ ```json
44
+ {
45
+ "request": { "method": "...", "url": "...", "headers": { }, "body": null },
46
+ "classification": { "kind": "llm|http.out|unknown", "vendor": "...", "sensitivity": "..." },
47
+ "trace": { "trace_id": "...", "span_id": "...", "parent_span_id": null }
48
+ }
49
+ ```
@@ -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,4 @@
1
+ from atherdlp.classifier.classify import classify_destination
2
+ from atherdlp.classifier.providers import PROVIDERS, register_provider
3
+
4
+ __all__ = ["PROVIDERS", "register_provider", "classify_destination"]
@@ -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)
@@ -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,3 @@
1
+ from atherdlp.context.trace import TraceContext, new_span_id, new_trace, new_trace_id
2
+
3
+ __all__ = ["TraceContext", "new_trace_id", "new_span_id", "new_trace"]
@@ -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,4 @@
1
+ from atherdlp.interceptor.base import InterceptedPayload, PayloadHandler
2
+ from atherdlp.interceptor.httpx_hook import install_httpx, uninstall_httpx
3
+
4
+ __all__ = ["InterceptedPayload", "PayloadHandler", "install_httpx", "uninstall_httpx"]
@@ -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
+
@@ -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
+ )
File without changes
@@ -0,0 +1,3 @@
1
+ from atherdlp.transport.sender import send_to_backend
2
+
3
+ __all__ = ["send_to_backend"]
@@ -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,28 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ import atherdlp
6
+
7
+
8
+ def main() -> None:
9
+ atherdlp.configure(endpoint="http://localhost:8473/events/http", api_key="dev")
10
+ atherdlp.install()
11
+ try:
12
+ # The destination doesn't need to exist for the interceptor to emit.
13
+ # Use any request that contains a value your SIT/policy can match.
14
+ httpx.post(
15
+ "https://api.openai.com/v1/chat/completions",
16
+ json={"secret": "sk-AAAA1111BBBB2222"},
17
+ timeout=2.0,
18
+ )
19
+ except Exception:
20
+ # Demo is about emitting events; ignore destination failures.
21
+ pass
22
+ finally:
23
+ atherdlp.uninstall()
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
28
+
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "atherdlp"
3
+ version = "0.0.2"
4
+ description = "Developer-first DLP HTTP interceptor (Stage 1)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Subham Singh Chauhan", email = "subhamchauhan1100@gmail.com" },
11
+ ]
12
+ keywords = ["dlp", "security", "httpx", "interceptor", "observability", "tracing"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Security",
23
+ "Topic :: Internet :: WWW/HTTP",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/AetherDLP/AtherDLP-sdk"
32
+ Repository = "https://github.com/AetherDLP/AtherDLP-sdk"
33
+ Issues = "https://github.com/AetherDLP/AtherDLP-sdk/issues"
34
+
35
+ [project.optional-dependencies]
36
+ test = [
37
+ "pytest>=8",
38
+ "pytest-asyncio>=0.24",
39
+ "respx>=0.21",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["hatchling>=1.25"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["atherdlp"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["atherdlp", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ asyncio_mode = "auto"
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ import atherdlp
10
+
11
+
12
+ def test_backend_emit_non_blocking_and_trace_headers() -> None:
13
+ seen: list[dict] = []
14
+ backend_called = threading.Event()
15
+ backend_headers: dict[str, str] = {}
16
+
17
+ def handler(payload: dict) -> None:
18
+ seen.append(payload)
19
+
20
+ atherdlp.set_handler(handler)
21
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.2)
22
+ atherdlp.install()
23
+
24
+ with respx.mock(assert_all_called=False) as router:
25
+ router.post("https://backend.local/events/http").mock(
26
+ side_effect=lambda request: _mark_backend_call(request, backend_called, backend_headers)
27
+ )
28
+ router.get("https://httpbin.org/get").respond(200, json={"ok": True})
29
+
30
+ r = httpx.get("https://httpbin.org/get")
31
+ assert r.status_code == 200
32
+
33
+ assert len(seen) == 1
34
+ trace_id = seen[0]["trace"]["trace_id"]
35
+
36
+ # Sender is async; wait briefly for delivery.
37
+ assert backend_called.wait(1.0) is True
38
+ assert backend_headers.get("x-ather-trace-id") == trace_id
39
+ assert backend_headers.get("x-correlation-id") == trace_id
40
+
41
+ atherdlp.uninstall()
42
+
43
+
44
+ def test_backend_emit_prefers_safe_inbound_x_correlation_id() -> None:
45
+ seen: list[dict] = []
46
+ backend_called = threading.Event()
47
+ backend_headers: dict[str, str] = {}
48
+
49
+ def handler(payload: dict) -> None:
50
+ seen.append(payload)
51
+
52
+ atherdlp.set_handler(handler)
53
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.2)
54
+ atherdlp.install()
55
+
56
+ inbound_cid = "acme-upstream-001"
57
+
58
+ with respx.mock(assert_all_called=False) as router:
59
+ router.post("https://backend.local/events/http").mock(
60
+ side_effect=lambda request: _mark_backend_call(request, backend_called, backend_headers)
61
+ )
62
+ router.get("https://httpbin.org/get").respond(200, json={"ok": True})
63
+
64
+ r = httpx.get(
65
+ "https://httpbin.org/get",
66
+ headers={"X-Correlation-Id": inbound_cid},
67
+ )
68
+ assert r.status_code == 200
69
+
70
+ assert len(seen) == 1
71
+ trace_id = seen[0]["trace"]["trace_id"]
72
+ assert trace_id != inbound_cid
73
+
74
+ assert backend_called.wait(1.0) is True
75
+ assert backend_headers.get("x-correlation-id") == inbound_cid
76
+ assert backend_headers.get("x-ather-trace-id") == trace_id
77
+
78
+ atherdlp.uninstall()
79
+
80
+
81
+ def test_backend_emit_falls_back_when_inbound_x_correlation_id_unsafe() -> None:
82
+ seen: list[dict] = []
83
+ backend_called = threading.Event()
84
+ backend_headers: dict[str, str] = {}
85
+
86
+ def handler(payload: dict) -> None:
87
+ seen.append(payload)
88
+
89
+ atherdlp.set_handler(handler)
90
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.2)
91
+ atherdlp.install()
92
+
93
+ bad_cid = "x" * 65
94
+
95
+ with respx.mock(assert_all_called=False) as router:
96
+ router.post("https://backend.local/events/http").mock(
97
+ side_effect=lambda request: _mark_backend_call(request, backend_called, backend_headers)
98
+ )
99
+ router.get("https://httpbin.org/get").respond(200, json={"ok": True})
100
+
101
+ r = httpx.get("https://httpbin.org/get", headers={"X-Correlation-Id": bad_cid})
102
+ assert r.status_code == 200
103
+
104
+ assert len(seen) == 1
105
+ trace_id = seen[0]["trace"]["trace_id"]
106
+
107
+ assert backend_called.wait(1.0) is True
108
+ assert backend_headers.get("x-correlation-id") == trace_id
109
+ assert bad_cid not in backend_headers.values()
110
+
111
+ atherdlp.uninstall()
112
+
113
+
114
+ def test_upstream_httpx_request_includes_propagated_trace_headers() -> None:
115
+ """Headers x-ather-trace-id / x-ather-span-id on the real outbound upstream request."""
116
+
117
+ upstream_headers: dict[str, str] = {}
118
+
119
+ def capture_upstream(request: httpx.Request) -> httpx.Response:
120
+ upstream_headers.clear()
121
+ upstream_headers.update({k.lower(): v for k, v in request.headers.items()})
122
+ return httpx.Response(200, json={"ok": True})
123
+
124
+ seen: list[dict] = []
125
+
126
+ def handler(payload: dict) -> None:
127
+ seen.append(payload)
128
+
129
+ atherdlp.set_handler(handler)
130
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.2)
131
+ atherdlp.install()
132
+
133
+ with respx.mock(assert_all_called=False) as router:
134
+ router.post("https://backend.local/events/http").respond(200, json={"ok": True})
135
+ router.get("https://upstream.example/up").mock(side_effect=capture_upstream)
136
+
137
+ r = httpx.get("https://upstream.example/up")
138
+ assert r.status_code == 200
139
+
140
+ assert len(seen) == 1
141
+ trace = seen[0]["trace"]
142
+ assert upstream_headers.get("x-ather-trace-id") == trace["trace_id"]
143
+ assert upstream_headers.get("x-ather-span-id") == trace["span_id"]
144
+
145
+ atherdlp.uninstall()
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_upstream_async_httpx_request_includes_propagated_trace_headers() -> None:
150
+ """Same as sync test: _AtherAsyncTransport injects trace headers on the upstream request."""
151
+
152
+ upstream_headers: dict[str, str] = {}
153
+
154
+ def capture_upstream(request: httpx.Request) -> httpx.Response:
155
+ upstream_headers.clear()
156
+ upstream_headers.update({k.lower(): v for k, v in request.headers.items()})
157
+ return httpx.Response(200, json={"ok": True})
158
+
159
+ seen: list[dict] = []
160
+
161
+ def handler(payload: dict) -> None:
162
+ seen.append(payload)
163
+
164
+ atherdlp.set_handler(handler)
165
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.2)
166
+ atherdlp.install()
167
+
168
+ try:
169
+ with respx.mock(assert_all_called=False) as router:
170
+ router.post("https://backend.local/events/http").respond(200, json={"ok": True})
171
+ router.get("https://upstream.example/async-up").mock(side_effect=capture_upstream)
172
+
173
+ async with httpx.AsyncClient() as client:
174
+ r = await client.get("https://upstream.example/async-up")
175
+
176
+ assert r.status_code == 200
177
+ assert len(seen) == 1
178
+ trace = seen[0]["trace"]
179
+ assert upstream_headers.get("x-ather-trace-id") == trace["trace_id"]
180
+ assert upstream_headers.get("x-ather-span-id") == trace["span_id"]
181
+ finally:
182
+ atherdlp.uninstall()
183
+
184
+
185
+ def test_backend_emit_fail_open_on_timeout() -> None:
186
+ atherdlp.configure(endpoint="https://backend.local/events/http", api_key="dev", timeout_s=0.001)
187
+ atherdlp.install()
188
+
189
+ with respx.mock(assert_all_called=False) as router:
190
+ router.post("https://backend.local/events/http").mock(side_effect=httpx.ReadTimeout("timeout"))
191
+ router.get("https://httpbin.org/get").respond(200, json={"ok": True})
192
+
193
+ r = httpx.get("https://httpbin.org/get")
194
+ assert r.status_code == 200
195
+
196
+ atherdlp.uninstall()
197
+
198
+
199
+ def _mark_backend_call(request: httpx.Request, ev: threading.Event, out: dict[str, str]) -> httpx.Response:
200
+ out.update({k.lower(): v for k, v in request.headers.items()})
201
+ ev.set()
202
+ return httpx.Response(200, json={"ok": True})
203
+
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from atherdlp.classifier.classify import classify_destination
4
+
5
+
6
+ def test_known_llm_host() -> None:
7
+ out = classify_destination("https://api.openai.com/v1/models")
8
+ assert out["kind"] == "llm"
9
+ assert out["vendor"] == "openai"
10
+
11
+
12
+ def test_unknown_host() -> None:
13
+ out = classify_destination("https://example.com/path")
14
+ assert out["kind"] == "unknown"
15
+ assert out["vendor"] is None
16
+
17
+
18
+ def test_case_insensitive() -> None:
19
+ out = classify_destination("https://API.OPENAI.COM/v1/models")
20
+ assert out["kind"] == "llm"
21
+
22
+
23
+ def test_url_with_port() -> None:
24
+ out = classify_destination("https://api.openai.com:443/v1/models")
25
+ assert out["kind"] == "llm"
26
+
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from atherdlp.context.trace import new_span_id, new_trace, new_trace_id
6
+
7
+
8
+ HEX32 = re.compile(r"^[0-9a-f]{32}$")
9
+ HEX16 = re.compile(r"^[0-9a-f]{16}$")
10
+
11
+
12
+ def test_trace_id_format() -> None:
13
+ tid = new_trace_id()
14
+ assert HEX32.match(tid)
15
+
16
+
17
+ def test_span_id_format() -> None:
18
+ sid = new_span_id()
19
+ assert HEX16.match(sid)
20
+
21
+
22
+ def test_new_trace_creates_ids() -> None:
23
+ t1 = new_trace()
24
+ t2 = new_trace()
25
+ assert HEX32.match(t1.trace_id)
26
+ assert HEX16.match(t1.span_id)
27
+ assert t1.parent_span_id is None
28
+ assert t1.trace_id != t2.trace_id
29
+ assert t1.span_id != t2.span_id
30
+
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ import atherdlp
7
+ from atherdlp.classifier.providers import register_provider
8
+
9
+
10
+ def test_httpbin_basic_intercept_and_classify() -> None:
11
+ register_provider("httpbin.org", {"kind": "test", "vendor": "httpbin"})
12
+
13
+ seen: list[dict] = []
14
+
15
+ def handler(payload: dict) -> None:
16
+ seen.append(payload)
17
+
18
+ atherdlp.set_handler(handler)
19
+ atherdlp.install()
20
+
21
+ with respx.mock:
22
+ respx.get("https://httpbin.org/get").respond(200, json={"ok": True})
23
+ r = httpx.get("https://httpbin.org/get")
24
+ assert r.status_code == 200
25
+
26
+ atherdlp.uninstall()
27
+
28
+ assert len(seen) == 1
29
+ payload = seen[0]
30
+ assert payload["request"]["method"] == "GET"
31
+ assert payload["request"]["url"] == "https://httpbin.org/get"
32
+ assert payload["classification"]["kind"] == "test"
33
+ assert payload["classification"]["vendor"] == "httpbin"
34
+ assert payload["trace"]["trace_id"]
35
+ assert payload["trace"]["span_id"]
36
+ assert payload["trace"]["parent_span_id"] is None
37
+
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ import atherdlp
7
+ from atherdlp.classifier.providers import register_provider
8
+
9
+
10
+ def test_httpbin_file_upload_is_extracted(tmp_path) -> None:
11
+ register_provider("httpbin.org", {"kind": "test", "vendor": "httpbin"})
12
+
13
+ p = tmp_path / "test.txt"
14
+ p.write_text("API_KEY=sk-1234567890\nhello world", encoding="utf-8")
15
+
16
+ seen: list[dict] = []
17
+
18
+ def handler(payload: dict) -> None:
19
+ seen.append(payload)
20
+
21
+ atherdlp.set_handler(handler)
22
+ atherdlp.install()
23
+
24
+ with respx.mock:
25
+ respx.post("https://httpbin.org/post").respond(200, json={"ok": True})
26
+ with p.open("rb") as f:
27
+ r = httpx.post("https://httpbin.org/post", files={"file": ("test.txt", f, "text/plain")})
28
+ assert r.status_code == 200
29
+
30
+ atherdlp.uninstall()
31
+
32
+ assert len(seen) == 1
33
+ body = seen[0]["request"]["body"]
34
+ assert set(body.keys()) == {"files", "fields"}
35
+ assert len(body["fields"]) == 0
36
+ assert len(body["files"]) == 1
37
+
38
+ file0 = body["files"][0]
39
+ assert file0["filename"] == "test.txt"
40
+ assert file0["content_type"] == "text/plain"
41
+ assert "API_KEY=sk-1234567890" in file0["content"]
42
+ assert "hello world" in file0["content"]
43
+ assert file0["size"] > 0
44
+
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ import atherdlp
7
+ from atherdlp.classifier.providers import register_provider
8
+
9
+
10
+ def test_httpbin_json_body_is_captured() -> None:
11
+ register_provider("httpbin.org", {"kind": "test", "vendor": "httpbin"})
12
+
13
+ seen_bodies: list[object] = []
14
+
15
+ def handler(payload: dict) -> None:
16
+ seen_bodies.append(payload["request"]["body"])
17
+
18
+ atherdlp.set_handler(handler)
19
+ atherdlp.install()
20
+
21
+ with respx.mock:
22
+ respx.post("https://httpbin.org/post").respond(200, json={"ok": True})
23
+ r = httpx.post("https://httpbin.org/post", json={"name": "subham", "secret": "123"})
24
+ assert r.status_code == 200
25
+
26
+ atherdlp.uninstall()
27
+
28
+ assert seen_bodies == [{"name": "subham", "secret": "123"}]
29
+
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ import atherdlp
7
+ from atherdlp.classifier.providers import register_provider
8
+
9
+
10
+ def test_httpbin_large_file_upload_is_sampled(tmp_path) -> None:
11
+ register_provider("httpbin.org", {"kind": "test", "vendor": "httpbin"})
12
+
13
+ p = tmp_path / "big.txt"
14
+ p.write_text("A" * (3 * 1024 * 1024), encoding="utf-8") # 3MB
15
+
16
+ seen: list[dict] = []
17
+
18
+ def handler(payload: dict) -> None:
19
+ seen.append(payload)
20
+
21
+ atherdlp.set_handler(handler)
22
+ atherdlp.install()
23
+
24
+ with respx.mock:
25
+ respx.post("https://httpbin.org/post").respond(200, json={"ok": True})
26
+ with p.open("rb") as f:
27
+ r = httpx.post("https://httpbin.org/post", files={"file": ("big.txt", f, "text/plain")})
28
+ assert r.status_code == 200
29
+
30
+ atherdlp.uninstall()
31
+
32
+ assert len(seen) == 1
33
+ body = seen[0]["request"]["body"]
34
+ assert len(body["files"]) == 1
35
+ file0 = body["files"][0]
36
+
37
+ assert file0["filename"] == "big.txt"
38
+ assert file0["content_type"] == "text/plain"
39
+ assert isinstance(file0["content"], dict)
40
+ assert file0["content"]["_sampled"] is True
41
+ assert file0["content"]["size"] == 3 * 1024 * 1024
42
+ assert isinstance(file0["content"]["samples"], list)
43
+ assert len(file0["content"]["samples"]) == 3
44
+ assert all(isinstance(s, str) for s in file0["content"]["samples"])
45
+