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.
- atherdlp-0.0.2/.gitignore +31 -0
- atherdlp-0.0.2/LICENSE +21 -0
- atherdlp-0.0.2/PKG-INFO +79 -0
- atherdlp-0.0.2/README.md +49 -0
- atherdlp-0.0.2/atherdlp/__init__.py +34 -0
- atherdlp-0.0.2/atherdlp/classifier/__init__.py +4 -0
- atherdlp-0.0.2/atherdlp/classifier/classify.py +13 -0
- atherdlp-0.0.2/atherdlp/classifier/providers.py +18 -0
- atherdlp-0.0.2/atherdlp/config.py +40 -0
- atherdlp-0.0.2/atherdlp/context/__init__.py +3 -0
- atherdlp-0.0.2/atherdlp/context/trace.py +28 -0
- atherdlp-0.0.2/atherdlp/interceptor/__init__.py +4 -0
- atherdlp-0.0.2/atherdlp/interceptor/base.py +18 -0
- atherdlp-0.0.2/atherdlp/interceptor/httpx_hook.py +234 -0
- atherdlp-0.0.2/atherdlp/limits.py +33 -0
- atherdlp-0.0.2/atherdlp/py.typed +0 -0
- atherdlp-0.0.2/atherdlp/transport/__init__.py +3 -0
- atherdlp-0.0.2/atherdlp/transport/sender.py +119 -0
- atherdlp-0.0.2/examples/demo.py +28 -0
- atherdlp-0.0.2/pyproject.toml +54 -0
- atherdlp-0.0.2/tests/test_backend_emit.py +203 -0
- atherdlp-0.0.2/tests/test_classifier.py +26 -0
- atherdlp-0.0.2/tests/test_context.py +30 -0
- atherdlp-0.0.2/tests/test_httpbin_basic.py +37 -0
- atherdlp-0.0.2/tests/test_httpbin_file_upload.py +44 -0
- atherdlp-0.0.2/tests/test_httpbin_json_body.py +29 -0
- atherdlp-0.0.2/tests/test_httpbin_large_file_upload.py +45 -0
|
@@ -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.
|
atherdlp-0.0.2/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
atherdlp-0.0.2/README.md
ADDED
|
@@ -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,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,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
|
+
|
|
@@ -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,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
|
+
|