cybat-sdk 0.1.1__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.
cybat_sdk/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .collector import CybatCollector, CybatOptions, resolve_status_filter
2
+
3
+ __all__ = ["CybatCollector", "CybatOptions", "resolve_status_filter"]
4
+
5
+ __version__ = "0.1.0"
cybat_sdk/collector.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ Buffers captured HTTP events and flushes them to ingest-api (behind API Gateway).
3
+ Never raises into the host app -- a broken or unreachable ingest endpoint must
4
+ never take down the app being instrumented. On repeated failure the batch is
5
+ dropped rather than retried indefinitely (Pub/Sub is the durability layer on
6
+ our side; this SDK only needs to survive transient network blips).
7
+
8
+ Mirrors SDK/node/src/index.ts's CybatCollector -- same options, same defaults,
9
+ same wire format. See SDK/README.md for the wire contract every language SDK
10
+ implements identically.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import threading
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable, Dict, List, Optional, Union
22
+
23
+ from .redact import mask_body, snippet_around
24
+
25
+ StatusFilterFn = Callable[[int], bool]
26
+ # 'all' | 'errors' | a predicate -- see resolve_status_filter.
27
+ StatusFilter = Union[str, StatusFilterFn]
28
+
29
+ _DEFAULT_GCP_INGEST_URL = "https://gcp-ingest.cybat.co"
30
+ _DEFAULT_AWS_INGEST_URL = "https://aws-ingest.cybat.co"
31
+ _BODY_DEFAULT_BYTES = 2048
32
+ _BODY_HARD_MAX_BYTES = 8192
33
+ _CAPTURABLE_CONTENT_TYPE = re.compile(r"^(application/(json|[\w.+-]+\+json|x-www-form-urlencoded)|text/)", re.I)
34
+
35
+
36
+ def resolve_status_filter(filt: Optional[StatusFilter]) -> StatusFilterFn:
37
+ """Resolves a StatusFilter option into a plain predicate, once, at construction."""
38
+ if callable(filt):
39
+ return filt
40
+ if filt == "errors":
41
+ return lambda status: status >= 400
42
+ return lambda status: True
43
+
44
+
45
+ @dataclass
46
+ class CybatOptions:
47
+ """
48
+ Python-facing options use snake_case (idiomatic here); the wire format each
49
+ event is serialized to is still the shared camelCase contract in
50
+ SDK/README.md -- that boundary is deliberate, not an inconsistency.
51
+ """
52
+
53
+ api_key: str
54
+ # The GCP project this app's traffic should be enforced against. Required for
55
+ # automatic blocking (VPC Firewall / Cloud Armor) -- without it, Cybat still
56
+ # detects and alerts on threats, but never touches infrastructure, since it has
57
+ # no project to act on. Also determines routing (mutually exclusive with
58
+ # aws_project_id -- one SDK instance targets one cloud).
59
+ gcp_project_id: Optional[str] = None
60
+ # NOT YET LIVE -- aws-ingest.cybat.co has no backend behind it yet. Setting this
61
+ # today means every batch fails to send until AWS-based ingestion ships.
62
+ aws_project_id: Optional[str] = None
63
+ # Optional label identifying which of the customer's services this is, shown on incidents.
64
+ service_name: Optional[str] = None
65
+ # Overrides cloud auto-detection entirely -- set this explicitly for local/staging testing.
66
+ ingest_url: Optional[str] = None
67
+ flush_interval_ms: int = 2000
68
+ max_batch_size: int = 100
69
+ # Retries on a failed send before the batch is dropped. Default: 2 (3 attempts total).
70
+ max_retries: int = 2
71
+ # Base delay in ms before the first retry; doubles on each subsequent retry
72
+ # (exponential backoff): 500ms then 1000ms by default.
73
+ retry_backoff_ms: int = 500
74
+ status_filter: StatusFilter = "all"
75
+ # Include the request body in each event. Default: off. See README's
76
+ # "Request bodies & privacy" -- redaction is not optional once this is on.
77
+ capture_body: bool = False
78
+ body_max_bytes: int = _BODY_DEFAULT_BYTES
79
+ body_status_filter: StatusFilter = "all"
80
+
81
+ def __post_init__(self) -> None:
82
+ if not self.api_key:
83
+ raise ValueError("[cybat-sdk] api_key is required")
84
+ self.body_max_bytes = min(_BODY_HARD_MAX_BYTES, max(256, self.body_max_bytes))
85
+ self.ingest_url = _resolve_ingest_url(self)
86
+
87
+
88
+ def _resolve_ingest_url(opts: CybatOptions) -> str:
89
+ """
90
+ Picks which cloud's ingestion endpoint to send to. An explicit ingest_url
91
+ always wins (escape hatch for local/staging testing). Otherwise the choice
92
+ follows whichever project ID was configured -- gcp_project_id and
93
+ aws_project_id are mutually exclusive because one SDK instance represents one
94
+ app running in one cloud; setting both is almost certainly a copy-paste
95
+ mistake, not a deliberate multi-cloud single app.
96
+ """
97
+ if opts.ingest_url:
98
+ return opts.ingest_url
99
+ if opts.gcp_project_id and opts.aws_project_id:
100
+ raise ValueError(
101
+ "[cybat-sdk] Set only one of gcp_project_id or aws_project_id -- a single SDK "
102
+ "instance targets a single cloud. Use two instances for an app split across both."
103
+ )
104
+ if opts.aws_project_id:
105
+ return _DEFAULT_AWS_INGEST_URL
106
+ return _DEFAULT_GCP_INGEST_URL
107
+
108
+
109
+ class CybatCollector:
110
+ def __init__(self, opts: CybatOptions) -> None:
111
+ self.opts = opts
112
+ self._buffer: List[Dict[str, Any]] = []
113
+ self._lock = threading.Lock()
114
+ self._should_capture = resolve_status_filter(opts.status_filter)
115
+ self._should_capture_body = resolve_status_filter(opts.body_status_filter)
116
+
117
+ self._stop_event = threading.Event()
118
+ self._timer_thread = threading.Thread(target=self._run_timer, daemon=True)
119
+ self._timer_thread.start()
120
+
121
+ def _run_timer(self) -> None:
122
+ interval_s = self.opts.flush_interval_ms / 1000
123
+ while not self._stop_event.wait(interval_s):
124
+ self.flush()
125
+
126
+ def wants(self, status: int) -> bool:
127
+ """Lets an adapter skip building an event that status_filter would discard anyway."""
128
+ return self._should_capture(status)
129
+
130
+ def capture_body_for(self, body: Any, content_type: Optional[str], status: int) -> Dict[str, Any]:
131
+ """
132
+ Redacts and caps a request body for inclusion in an event. Returns {} (never
133
+ the raw body) when capture is off, the response doesn't match
134
+ body_status_filter, the body is empty / binary / multipart, or redaction
135
+ fails for any reason.
136
+
137
+ Returned keys are the WIRE names (camelCase) -- this dict is merged
138
+ directly into the outgoing event.
139
+ """
140
+ if not self.opts.capture_body or not self._should_capture_body(status):
141
+ return {}
142
+ try:
143
+ if body is None or body == "" or body == {}:
144
+ return {}
145
+ if isinstance(body, (bytes, bytearray)):
146
+ return {}
147
+ if content_type and not _CAPTURABLE_CONTENT_TYPE.search(content_type):
148
+ return {}
149
+
150
+ masked = mask_body(body)
151
+ text = masked if isinstance(masked, str) else json.dumps(masked)
152
+ excerpt = snippet_around(text, self.opts.body_max_bytes)
153
+ result: Dict[str, Any] = {"body": excerpt}
154
+ if excerpt != text:
155
+ result["bodyTruncated"] = True
156
+ return result
157
+ except Exception:
158
+ return {} # when in doubt, send nothing rather than something unredacted
159
+
160
+ def record(self, event: Dict[str, Any]) -> None:
161
+ """
162
+ Framework integrations call this unconditionally with every request's
163
+ captured event -- the status_filter check happens once, here, so every
164
+ integration gets identical filtering behavior for free.
165
+ """
166
+ if not self._should_capture(event.get("status", 0)):
167
+ return
168
+ should_flush = False
169
+ with self._lock:
170
+ self._buffer.append(event)
171
+ if len(self._buffer) >= self.opts.max_batch_size:
172
+ should_flush = True
173
+ if should_flush:
174
+ self.flush()
175
+
176
+ def flush(self) -> None:
177
+ with self._lock:
178
+ if not self._buffer:
179
+ return
180
+ batch, self._buffer = self._buffer, []
181
+ self._send(batch, 0)
182
+
183
+ def _send(self, batch: List[Dict[str, Any]], attempt: int) -> None:
184
+ try:
185
+ data = json.dumps(batch).encode("utf-8")
186
+ req = urllib.request.Request(
187
+ self.opts.ingest_url,
188
+ data=data,
189
+ method="POST",
190
+ headers={"Content-Type": "application/json", "x-api-key": self.opts.api_key},
191
+ )
192
+ with urllib.request.urlopen(req, timeout=10):
193
+ pass # 2xx/3xx -- response body is never needed
194
+ except urllib.error.HTTPError as e:
195
+ if attempt < self.opts.max_retries:
196
+ return self._retry(batch, attempt)
197
+ print(f"[cybat-sdk] ingest rejected batch ({e.code}), dropping {len(batch)} event(s)")
198
+ except Exception as e: # network error, timeout, DNS failure, etc.
199
+ if attempt < self.opts.max_retries:
200
+ return self._retry(batch, attempt)
201
+ print(f"[cybat-sdk] failed to reach ingest endpoint, dropping {len(batch)} event(s): {e}")
202
+
203
+ def _retry(self, batch: List[Dict[str, Any]], attempt: int) -> None:
204
+ """Exponential backoff: attempt 0 waits retry_backoff_ms, attempt 1 waits 2x that, etc."""
205
+ delay_s = (self.opts.retry_backoff_ms * (2**attempt)) / 1000
206
+ timer = threading.Timer(delay_s, self._send, args=(batch, attempt + 1))
207
+ timer.daemon = True
208
+ timer.start()
209
+
210
+ def stop(self) -> None:
211
+ """Stops the flush timer. Call on graceful shutdown if you need a clean exit."""
212
+ self._stop_event.set()
File without changes
@@ -0,0 +1,143 @@
1
+ """
2
+ ASGI middleware -- works with FastAPI, Starlette, or any raw ASGI app. No
3
+ FastAPI/Starlette import needed: this speaks the ASGI protocol (scope /
4
+ receive / send) directly, not a framework-specific middleware base class, so
5
+ it works correctly with streaming responses too.
6
+
7
+ # FastAPI / Starlette
8
+ from cybat_sdk.integrations.asgi import CybatASGIMiddleware
9
+ app.add_middleware(CybatASGIMiddleware, api_key=os.environ["CYBAT_API_KEY"], gcp_project_id="my-gcp-project")
10
+
11
+ # Any other ASGI app
12
+ app = CybatASGIMiddleware(app, api_key=..., gcp_project_id=...)
13
+
14
+ FastAPI/Starlette's `add_middleware(cls, **options)` instantiates `cls(app, **options)`,
15
+ which is exactly this class's constructor -- so it plugs directly into their
16
+ middleware stack with no adapter needed.
17
+
18
+ Behind your own load balancer or reverse proxy? Configure Starlette's
19
+ ProxyHeadersMiddleware (or your ASGI server's --proxy-headers /
20
+ --forwarded-allow-ips) -- otherwise `scope["client"]` is your proxy's address,
21
+ not the real client's.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import datetime
27
+ import json
28
+ import time
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from ..collector import CybatCollector, CybatOptions
32
+ from ..redact import mask_string
33
+
34
+ _UNCAPTURABLE_PREFIXES = ("multipart/form-data",)
35
+
36
+
37
+ def _iso(epoch_s: float) -> str:
38
+ return (
39
+ datetime.datetime.fromtimestamp(epoch_s, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
40
+ + "Z"
41
+ )
42
+
43
+
44
+ def _headers_dict(raw_headers: List[tuple]) -> Dict[str, str]:
45
+ out: Dict[str, str] = {}
46
+ for k, v in raw_headers:
47
+ key = k.decode("latin1").lower()
48
+ # Multiple headers with the same name (rare for the ones we read) --
49
+ # first one wins, matching how most frameworks expose a single value.
50
+ out.setdefault(key, v.decode("latin1"))
51
+ return out
52
+
53
+
54
+ class CybatASGIMiddleware:
55
+ def __init__(self, app: Any, **options: Any) -> None:
56
+ self.app = app
57
+ self.opts = CybatOptions(**options)
58
+ self.collector = CybatCollector(self.opts)
59
+
60
+ async def __call__(self, scope: dict, receive, send) -> None:
61
+ if scope.get("type") != "http":
62
+ await self.app(scope, receive, send)
63
+ return
64
+
65
+ start = time.time()
66
+ state: Dict[str, Any] = {"status": 200, "headers": {}}
67
+ request_body_chunks: List[bytes] = []
68
+
69
+ async def receive_wrapper():
70
+ message = await receive()
71
+ if self.opts.capture_body and message.get("type") == "http.request":
72
+ body = message.get("body")
73
+ if body:
74
+ request_body_chunks.append(body)
75
+ return message
76
+
77
+ async def send_wrapper(message: dict):
78
+ if message.get("type") == "http.response.start":
79
+ state["status"] = message.get("status", 200)
80
+ state["headers"] = _headers_dict(message.get("headers", []))
81
+ await send(message)
82
+
83
+ await self.app(scope, receive_wrapper, send_wrapper)
84
+
85
+ try:
86
+ self._record(scope, state, request_body_chunks, start)
87
+ except Exception:
88
+ pass # instrumentation must never break the host app
89
+
90
+ def _record(self, scope: dict, state: dict, body_chunks: List[bytes], start: float) -> None:
91
+ status = state["status"]
92
+ if not self.collector.wants(status):
93
+ return
94
+
95
+ req_headers = _headers_dict(scope.get("headers", []))
96
+ qs = (scope.get("query_string") or b"").decode("utf-8", errors="replace")
97
+ url = f"{scope.get('path', '')}?{qs}" if qs else scope.get("path", "")
98
+ client = scope.get("client") or (None, None)
99
+
100
+ response_headers = state["headers"]
101
+ try:
102
+ bytes_sent = int(response_headers.get("content-length", 0) or 0)
103
+ except ValueError:
104
+ bytes_sent = 0
105
+
106
+ event: Dict[str, Any] = {
107
+ "method": scope.get("method", ""),
108
+ "url": mask_string(url),
109
+ "status": status,
110
+ "remoteIp": client[0] or "unknown",
111
+ "latencyMs": int((time.time() - start) * 1000),
112
+ "bytesSent": bytes_sent,
113
+ "timestamp": _iso(start),
114
+ "projectId": self.opts.gcp_project_id or self.opts.aws_project_id,
115
+ "serviceName": self.opts.service_name,
116
+ }
117
+ user_agent = req_headers.get("user-agent")
118
+ referer = req_headers.get("referer") or req_headers.get("referrer")
119
+ if user_agent:
120
+ event["userAgent"] = mask_string(user_agent)
121
+ if referer:
122
+ event["referer"] = mask_string(referer)
123
+
124
+ content_type = req_headers.get("content-type")
125
+ event.update(self.collector.capture_body_for(_parse_body(body_chunks, content_type), content_type, status))
126
+ self.collector.record(event)
127
+
128
+
129
+ def _parse_body(chunks: List[bytes], content_type: Optional[str]) -> Any:
130
+ if not chunks:
131
+ return None
132
+ ct = (content_type or "").lower()
133
+ if any(ct.startswith(p) for p in _UNCAPTURABLE_PREFIXES):
134
+ return None # never touch multipart -- may hold large file uploads
135
+ raw = b"".join(chunks)
136
+ if not raw:
137
+ return None
138
+ if "json" in ct:
139
+ try:
140
+ return json.loads(raw.decode("utf-8"))
141
+ except Exception:
142
+ return None
143
+ return raw.decode("utf-8", errors="replace")
@@ -0,0 +1,142 @@
1
+ """
2
+ Django integration.
3
+
4
+ # settings.py
5
+ MIDDLEWARE = [..., "cybat_sdk.integrations.django.CybatMiddleware"]
6
+ CYBAT_API_KEY = os.environ["CYBAT_API_KEY"]
7
+ CYBAT_GCP_PROJECT_ID = "my-gcp-project" # optional -- enables automatic blocking
8
+
9
+ Django instantiates middleware classes itself with no way to pass constructor
10
+ kwargs, so configuration comes from `django.conf.settings` instead of a
11
+ function call, unlike the Flask/ASGI integrations. Every `CYBAT_*` setting
12
+ maps to the matching CybatOptions field (see the table in the package README).
13
+
14
+ Requires Django to be installed already -- it is not a dependency of
15
+ cybat-sdk itself, this module just isn't importable without it.
16
+
17
+ Behind your own load balancer or reverse proxy? Configure Django's
18
+ SECURE_PROXY_SSL_HEADER / trust your proxy's X-Forwarded-For the way you
19
+ already do for `request.META["REMOTE_ADDR"]` -- otherwise every event will
20
+ report the proxy's address instead of the real client's.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime
26
+ import json
27
+ import time
28
+ from typing import Any, Optional
29
+
30
+ from django.conf import settings
31
+
32
+ from ..collector import CybatCollector, CybatOptions
33
+ from ..redact import mask_string
34
+
35
+ _collector: Optional[CybatCollector] = None
36
+ _opts: Optional[CybatOptions] = None
37
+
38
+
39
+ def _options_from_settings() -> CybatOptions:
40
+ return CybatOptions(
41
+ api_key=getattr(settings, "CYBAT_API_KEY", ""),
42
+ gcp_project_id=getattr(settings, "CYBAT_GCP_PROJECT_ID", None),
43
+ aws_project_id=getattr(settings, "CYBAT_AWS_PROJECT_ID", None),
44
+ service_name=getattr(settings, "CYBAT_SERVICE_NAME", None),
45
+ ingest_url=getattr(settings, "CYBAT_INGEST_URL", None),
46
+ flush_interval_ms=getattr(settings, "CYBAT_FLUSH_INTERVAL_MS", 2000),
47
+ max_batch_size=getattr(settings, "CYBAT_MAX_BATCH_SIZE", 100),
48
+ max_retries=getattr(settings, "CYBAT_MAX_RETRIES", 2),
49
+ retry_backoff_ms=getattr(settings, "CYBAT_RETRY_BACKOFF_MS", 500),
50
+ status_filter=getattr(settings, "CYBAT_STATUS_FILTER", "all"),
51
+ capture_body=getattr(settings, "CYBAT_CAPTURE_BODY", False),
52
+ body_max_bytes=getattr(settings, "CYBAT_BODY_MAX_BYTES", 2048),
53
+ body_status_filter=getattr(settings, "CYBAT_BODY_STATUS_FILTER", "all"),
54
+ )
55
+
56
+
57
+ def get_collector() -> CybatCollector:
58
+ """Returns the process-wide collector, creating it from settings on first use. Exposed so callers can flush()/stop() it, e.g. in tests or on shutdown."""
59
+ global _collector, _opts
60
+ if _collector is None:
61
+ _opts = _options_from_settings()
62
+ _collector = CybatCollector(_opts)
63
+ return _collector
64
+
65
+
66
+ def _extract_body(request) -> Any:
67
+ ct = (request.content_type or "").lower()
68
+ if "multipart/form-data" in ct:
69
+ return None # never touch multipart -- may hold large file uploads
70
+ if "json" in ct:
71
+ try:
72
+ return json.loads(request.body.decode("utf-8"))
73
+ except Exception:
74
+ return None
75
+ if "x-www-form-urlencoded" in ct:
76
+ # Reading request.POST (not .body) avoids Django's "you cannot access
77
+ # body after reading from request's data stream" for form submissions,
78
+ # since Django's own form parser already consumed the stream for us.
79
+ return dict(request.POST)
80
+ try:
81
+ return request.body.decode("utf-8", errors="replace")
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def _iso(epoch_s: float) -> str:
87
+ return (
88
+ datetime.datetime.fromtimestamp(epoch_s, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
89
+ + "Z"
90
+ )
91
+
92
+
93
+ class CybatMiddleware:
94
+ """Django middleware -- add to MIDDLEWARE, configuration comes from settings.py (see module docstring)."""
95
+
96
+ def __init__(self, get_response):
97
+ self.get_response = get_response
98
+ self.collector = get_collector()
99
+ self.opts = _opts
100
+
101
+ def __call__(self, request):
102
+ start = time.time()
103
+ response = self.get_response(request)
104
+ try:
105
+ self._record(request, response, start)
106
+ except Exception:
107
+ pass # instrumentation must never break the host app
108
+ return response
109
+
110
+ def _record(self, request, response, start: float) -> None:
111
+ if not self.collector.wants(response.status_code):
112
+ return
113
+
114
+ qs = request.META.get("QUERY_STRING", "")
115
+ url = f"{request.path}?{qs}" if qs else request.path
116
+ referer = request.META.get("HTTP_REFERER")
117
+ user_agent = request.META.get("HTTP_USER_AGENT")
118
+
119
+ content_length = response.get("Content-Length")
120
+ try:
121
+ bytes_sent = int(content_length) if content_length is not None else len(getattr(response, "content", b""))
122
+ except (TypeError, ValueError):
123
+ bytes_sent = 0
124
+
125
+ event = {
126
+ "method": request.method,
127
+ "url": mask_string(url),
128
+ "status": response.status_code,
129
+ "remoteIp": request.META.get("REMOTE_ADDR", "unknown"),
130
+ "latencyMs": int((time.time() - start) * 1000),
131
+ "bytesSent": bytes_sent,
132
+ "timestamp": _iso(start),
133
+ "projectId": self.opts.gcp_project_id or self.opts.aws_project_id,
134
+ "serviceName": self.opts.service_name,
135
+ }
136
+ if user_agent:
137
+ event["userAgent"] = mask_string(user_agent)
138
+ if referer:
139
+ event["referer"] = mask_string(referer)
140
+
141
+ event.update(self.collector.capture_body_for(_extract_body(request), request.content_type, response.status_code))
142
+ self.collector.record(event)
@@ -0,0 +1,102 @@
1
+ """
2
+ Flask integration.
3
+
4
+ from flask import Flask
5
+ from cybat_sdk.integrations.flask import init_app
6
+
7
+ app = Flask(__name__)
8
+ init_app(app, api_key=os.environ["CYBAT_API_KEY"], gcp_project_id="my-gcp-project")
9
+
10
+ Requires Flask to be installed in your project already -- it is not a
11
+ dependency of cybat-sdk itself, this module just isn't importable without it.
12
+
13
+ If your app sits behind a load balancer or reverse proxy you own, wrap it with
14
+ Werkzeug's ProxyFix (or configure your proxy to set X-Forwarded-For correctly)
15
+ -- otherwise `request.remote_addr` resolves to the proxy's address instead of
16
+ the real client, and every event will look like it came from one source.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from typing import Any, Optional
23
+
24
+ from flask import Flask, Response, g, request
25
+
26
+ from ..collector import CybatCollector, CybatOptions
27
+ from ..redact import mask_string
28
+
29
+ _UNCAPTURABLE_CONTENT = ("multipart/form-data",)
30
+
31
+
32
+ def _extract_body(req) -> Any:
33
+ ct = (req.content_type or "").lower()
34
+ if any(u in ct for u in _UNCAPTURABLE_CONTENT):
35
+ return None # never touch multipart -- may hold large file uploads
36
+ if "json" in ct:
37
+ return req.get_json(silent=True)
38
+ if "x-www-form-urlencoded" in ct:
39
+ return req.form.to_dict()
40
+ try:
41
+ # capture_body_for's own content-type check drops anything that isn't
42
+ # actually text/json/form, so a generic text read here is harmless.
43
+ return req.get_data(as_text=True)
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def init_app(app: Flask, **options: Any) -> CybatCollector:
49
+ """Wires the collector into `app` via before_request/after_request hooks. Returns the collector (call .stop() on shutdown if you need a clean exit)."""
50
+ opts = CybatOptions(**options)
51
+ collector = CybatCollector(opts)
52
+
53
+ @app.before_request
54
+ def _cybat_mark_start() -> None:
55
+ g._cybat_start = time.time()
56
+
57
+ @app.after_request
58
+ def _cybat_record(response: Response) -> Response:
59
+ try:
60
+ _build_and_record(collector, opts, response)
61
+ except Exception:
62
+ pass # instrumentation must never break the host app
63
+ return response
64
+
65
+ return collector
66
+
67
+
68
+ def _build_and_record(collector: CybatCollector, opts: CybatOptions, response: Response) -> None:
69
+ if not collector.wants(response.status_code):
70
+ return
71
+
72
+ start: float = getattr(g, "_cybat_start", time.time())
73
+ qs = request.query_string.decode("utf-8", errors="replace")
74
+ url = f"{request.path}?{qs}" if qs else request.path
75
+
76
+ referer = request.headers.get("Referer") or request.headers.get("Referrer")
77
+ user_agent = request.headers.get("User-Agent")
78
+
79
+ event = {
80
+ "method": request.method,
81
+ "url": mask_string(url),
82
+ "status": response.status_code,
83
+ "remoteIp": request.remote_addr or "unknown",
84
+ "latencyMs": int((time.time() - start) * 1000),
85
+ "bytesSent": response.content_length or 0,
86
+ "timestamp": _iso(start),
87
+ "projectId": opts.gcp_project_id or opts.aws_project_id,
88
+ "serviceName": opts.service_name,
89
+ }
90
+ if user_agent:
91
+ event["userAgent"] = mask_string(user_agent)
92
+ if referer:
93
+ event["referer"] = mask_string(referer)
94
+
95
+ event.update(collector.capture_body_for(_extract_body(request), request.content_type, response.status_code))
96
+ collector.record(event)
97
+
98
+
99
+ def _iso(epoch_s: float) -> str:
100
+ import datetime
101
+
102
+ return datetime.datetime.fromtimestamp(epoch_s, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
cybat_sdk/redact.py ADDED
@@ -0,0 +1,309 @@
1
+ """
2
+ Layer 1 of the data-protection stack: fast, local, dependency-free redaction.
3
+
4
+ NOTE: this is a line-for-line port of SDK/node/src/redact.ts (also duplicated in
5
+ soc-ai-agent/services/redact.ts and intelligence-api/services/redact.ts, separate
6
+ deployables, no shared package) — keep all four in sync. Same patterns, same
7
+ precedence, same idempotence guarantee.
8
+
9
+ The SDK runs this on the customer's side BEFORE anything leaves their
10
+ infrastructure; the server runs it again (the SDK may be old, modified or
11
+ hostile) before persisting, before prompting the model, and before alerts. It
12
+ is therefore idempotent: masking already-masked text leaves it unchanged.
13
+
14
+ Design point: a redacted secret is replaced by a *shape descriptor*, not just
15
+ "[REDACTED]" -- `[REDACTED len=13 sus=quote,comment]`. Blanking a `password`
16
+ field entirely would also blank the SQL injection an attacker put in it; the
17
+ descriptor keeps the attack signal (length + which suspicious character
18
+ classes it contained) without keeping the value.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ from typing import Any, List, Tuple, Optional
26
+ from urllib.parse import unquote_plus
27
+
28
+ REDACTED = "[REDACTED"
29
+
30
+ # -- Attack-shape descriptor (content-free) ----------------------------------
31
+
32
+ _SUSPICIOUS: List[Tuple[str, re.Pattern]] = [
33
+ ("quote", re.compile(r"""['"`]|%27|%22""", re.I)),
34
+ ("comment", re.compile(r"--|/\*|#\s*$")),
35
+ ("sqlkw", re.compile(r"\b(union|select|insert|update|delete|drop|sleep|benchmark|waitfor)\b", re.I)),
36
+ ("angle", re.compile(r"[<>]|%3c|%3e", re.I)),
37
+ ("semicolon", re.compile(r";")),
38
+ ("traversal", re.compile(r"\.\.[/\\]|%2e%2e", re.I)),
39
+ ("cmdchar", re.compile(r"[|&$`]|\$\(")),
40
+ ("template", re.compile(r"\{\{|\$\{|<%")),
41
+ ("nullbyte", re.compile(r"%00|\x00")),
42
+ ]
43
+
44
+
45
+ def redacted_shape(value: Any) -> str:
46
+ """`[REDACTED len=N sus=a,b]` -- no characters of the original survive."""
47
+ s = "" if value is None else str(value)
48
+ sus = [name for name, pat in _SUSPICIOUS if pat.search(s)]
49
+ suffix = f" sus={','.join(sus)}" if sus else ""
50
+ return f"{REDACTED} len={len(s)}{suffix}]"
51
+
52
+
53
+ # -- Credit cards (Luhn) ------------------------------------------------------
54
+
55
+ def luhn_valid(digits: str) -> bool:
56
+ if not re.fullmatch(r"\d{13,19}", digits):
57
+ return False
58
+ total = 0
59
+ alt = False
60
+ for ch in reversed(digits):
61
+ n = ord(ch) - 48
62
+ if alt:
63
+ n *= 2
64
+ if n > 9:
65
+ n -= 9
66
+ total += n
67
+ alt = not alt
68
+ return total % 10 == 0
69
+
70
+
71
+ def _mask_card(digits: str) -> str:
72
+ return f"XXXX-XXXX-XXXX-{digits[-4:]}"
73
+
74
+
75
+ # -- Emails --------------------------------------------------------------------
76
+
77
+ def mask_email(local: str, domain: str) -> str:
78
+ first = local[0] if local else ""
79
+ last = local[-1] if len(local) > 2 else ""
80
+ return f"{first}***{last}@{domain}"
81
+
82
+
83
+ # `*` is in the local-part class so an already-masked address matches as a whole
84
+ # (and is then left alone), instead of its tail being re-masked.
85
+ # {1,64} is the RFC limit for a local part -- and, more to the point, it makes the
86
+ # scan linear: an unbounded `+` re-scans the whole remaining run from every start
87
+ # offset.
88
+ _EMAIL_RE = re.compile(
89
+ r"([A-Za-z0-9._%+*-]{1,64})(?:@|%40)([A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})*\.[A-Za-z]{2,24})"
90
+ )
91
+ _CARD_RE = re.compile(r"\b\d(?:[ -]?\d){12,18}\b")
92
+ _JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*")
93
+ _BEARER_RE = re.compile(r"\b(Bearer|Basic)\s+(?!\[REDACTED)[A-Za-z0-9._~+/=-]{8,}", re.I)
94
+ _PRIVKEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)")
95
+ _KEY_TOKEN_RES: List[re.Pattern] = [
96
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
97
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), # Google API key
98
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), # GitHub tokens
99
+ re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), # Slack tokens
100
+ re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b"), # Stripe keys
101
+ ]
102
+
103
+ # key=value pairs whose *name* says the value is a secret (URL query strings, form
104
+ # bodies, cookie-style strings). The leading class stops `sort_key=` and `monkey=`
105
+ # from matching. The value runs to the next `&`, `#`, newline or "; " --
106
+ # deliberately NOT stopping at quotes or spaces, because a secret (or an
107
+ # attacker's payload standing in for one) routinely contains them, and stopping
108
+ # early would leave the rest of it unmasked.
109
+ _PARAM_SECRET_RE = re.compile(
110
+ r"((?:^|[?&;,\s\"'])(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|apikey|"
111
+ r"client[_-]?secret|secret|password|passwd|passphrase|pwd|auth|authorization|session(?:[_-]?id)?|"
112
+ r"sid|signature|sig|key|credential|cc[_-]?num(?:ber)?|card[_-]?(?:number|no|num)?|cvv|cvc|ssn|iban)=)"
113
+ r"((?:(?!;\s)[^&#\r\n])+)",
114
+ re.I,
115
+ )
116
+
117
+ # Input is attacker-controlled and some patterns (emails) are quadratic on long
118
+ # runs with no delimiter, so bound the work. Anything longer is cut, not skipped:
119
+ # dropping the tail is safe; leaving it unscanned would not be.
120
+ _MAX_INPUT_CHARS = 65536
121
+
122
+ # `"key": "value"` pairs inside JSON text. Needed on top of mask_value because a
123
+ # body that was truncated mid-way (the SDK caps bodies) no longer parses as JSON,
124
+ # and this is the only pass left that can still see its keys. Bounded quantifiers
125
+ # keep it linear; the key test is the same one mask_value uses.
126
+ _JSON_PAIR_RE = re.compile(r'"([^"\\]{1,64})"(\s*:\s*)(?:"((?:[^"\\]|\\.){0,4000})"|(-?\d{1,25}))')
127
+
128
+ _SENSITIVE_KEY_RE = re.compile(
129
+ r"(pass|pwd|secret|token|auth|cookie|session|credential|private|api[_-]?key|apikey|card|cvv|cvc|ssn|iban|\bpin\b)",
130
+ re.I,
131
+ )
132
+ _CARD_KEY_RE = re.compile(r"(card|cc[_-]?num|ccnum|pan)", re.I)
133
+
134
+
135
+ def _safe_decode(v: str) -> str:
136
+ try:
137
+ return unquote_plus(v)
138
+ except Exception:
139
+ return v
140
+
141
+
142
+ def mask_string(text: Optional[str]) -> Optional[str]:
143
+ """Redacts secrets and personal data inside free text. Idempotent."""
144
+ if not text:
145
+ return text
146
+ s = text if len(text) <= _MAX_INPUT_CHARS else f"{text[:_MAX_INPUT_CHARS]}\u2026[truncated]"
147
+
148
+ s = _PRIVKEY_RE.sub(f"{REDACTED} private-key]", s)
149
+ s = _JWT_RE.sub(f"{REDACTED} jwt]", s)
150
+ s = _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}]", s)
151
+ for pat in _KEY_TOKEN_RES:
152
+ s = pat.sub(f"{REDACTED} key]", s)
153
+
154
+ def _param_repl(m: "re.Match") -> str:
155
+ prefix, value = m.group(1), m.group(2)
156
+ if value.startswith(REDACTED):
157
+ return m.group(0)
158
+ return f"{prefix}{redacted_shape(_safe_decode(value))}"
159
+
160
+ s = _PARAM_SECRET_RE.sub(_param_repl, s)
161
+
162
+ def _json_pair_repl(m: "re.Match") -> str:
163
+ key, colon, str_val, num_val = m.group(1), m.group(2), m.group(3), m.group(4)
164
+ if not _SENSITIVE_KEY_RE.search(key):
165
+ return m.group(0)
166
+ raw = str_val if str_val is not None else (num_val if num_val is not None else "")
167
+ if raw.startswith(REDACTED) or raw.startswith("XXXX-XXXX-XXXX-"):
168
+ return m.group(0)
169
+ digits = re.sub(r"\D", "", raw)
170
+ masked = _mask_card(digits) if _CARD_KEY_RE.search(key) and len(digits) >= 12 else redacted_shape(raw)
171
+ return f'"{key}"{colon}"{masked}"'
172
+
173
+ s = _JSON_PAIR_RE.sub(_json_pair_repl, s)
174
+
175
+ def _card_repl(m: "re.Match") -> str:
176
+ digits = re.sub(r"[ -]", "", m.group(0))
177
+ return _mask_card(digits) if luhn_valid(digits) else m.group(0)
178
+
179
+ s = _CARD_RE.sub(_card_repl, s)
180
+
181
+ def _email_repl(m: "re.Match") -> str:
182
+ local, domain = m.group(1), m.group(2)
183
+ return m.group(0) if "***" in local else mask_email(local, domain)
184
+
185
+ s = _EMAIL_RE.sub(_email_repl, s)
186
+
187
+ return s
188
+
189
+
190
+ # -- Structured values (parsed JSON / form bodies) ----------------------------
191
+
192
+ _MAX_DEPTH = 6
193
+ _MAX_ITEMS = 50
194
+
195
+
196
+ def mask_value(value: Any, depth: int = 0) -> Any:
197
+ """
198
+ Deep-masks a parsed body. Values under sensitive key names become a shape
199
+ descriptor (card-number fields keep only the last 4 digits); every other
200
+ string goes through mask_string. Depth and width are bounded so a hostile
201
+ payload can't make this expensive.
202
+ """
203
+ if value is None:
204
+ return value
205
+ if isinstance(value, str):
206
+ return mask_string(value)
207
+ if isinstance(value, bool) or isinstance(value, (int, float)):
208
+ return value
209
+ if depth >= _MAX_DEPTH:
210
+ return f"{REDACTED} depth-limit]"
211
+
212
+ if isinstance(value, (list, tuple)):
213
+ out = [mask_value(v, depth + 1) for v in list(value)[:_MAX_ITEMS]]
214
+ if len(value) > _MAX_ITEMS:
215
+ out.append(f"\u2026[{len(value) - _MAX_ITEMS} more items]")
216
+ return out
217
+
218
+ if isinstance(value, dict):
219
+ out: dict = {}
220
+ entries = list(value.items())
221
+ for k, v in entries[:_MAX_ITEMS]:
222
+ if _SENSITIVE_KEY_RE.search(str(k)) and isinstance(v, (str, int, float)) and not isinstance(v, bool):
223
+ raw = str(v)
224
+ if raw.startswith(REDACTED) or raw.startswith("XXXX-XXXX-XXXX-"):
225
+ out[k] = raw # already masked
226
+ continue
227
+ digits = re.sub(r"\D", "", raw)
228
+ out[k] = _mask_card(digits) if _CARD_KEY_RE.search(str(k)) and len(digits) >= 12 else redacted_shape(raw)
229
+ else:
230
+ out[k] = mask_value(v, depth + 1)
231
+ if len(entries) > _MAX_ITEMS:
232
+ out["\u2026"] = f"{len(entries) - _MAX_ITEMS} more keys"
233
+ return out
234
+
235
+ return str(value)
236
+
237
+
238
+ def mask_body(body: Any) -> Any:
239
+ """
240
+ Masks a request body of unknown shape: an already-parsed object, JSON text,
241
+ form-encoded text, or plain / truncated text. Shared by the SDK (client
242
+ side, before anything is sent) and the server (second pass), so both apply
243
+ the same rules.
244
+ """
245
+ if body is None:
246
+ return body
247
+ if not isinstance(body, str):
248
+ return mask_value(body)
249
+ trimmed = body.strip()
250
+ if trimmed.startswith("{") or trimmed.startswith("["):
251
+ try:
252
+ return json.dumps(mask_value(json.loads(trimmed)))
253
+ except (ValueError, TypeError):
254
+ pass # truncated JSON -- text pass below
255
+ return mask_string(body)
256
+
257
+
258
+ # -- Truncation (Layer 2) -----------------------------------------------------
259
+
260
+ def _byte_len(s: str) -> int:
261
+ return len(s.encode("utf-8"))
262
+
263
+
264
+ def cap_text(s: str, max_bytes: int) -> str:
265
+ """Byte-aware cap. Appends a marker so a reader knows content was dropped."""
266
+ if _byte_len(s) <= max_bytes:
267
+ return s
268
+ raw = s.encode("utf-8")
269
+ head_bytes = raw[: max(0, max_bytes - 32)]
270
+ # errors='ignore' silently drops a multi-byte sequence cut mid-character --
271
+ # equivalent to the Node version's Buffer decode + strip-trailing-replacement-char.
272
+ head = head_bytes.decode("utf-8", errors="ignore")
273
+ dropped = _byte_len(s) - _byte_len(head)
274
+ return f"{head}\u2026[truncated {dropped} bytes]"
275
+
276
+
277
+ _TRIGGER_RES: List[re.Pattern] = [
278
+ re.compile(r"'\s*(or|and)\s+['\d]", re.I),
279
+ re.compile(r"union\s+select", re.I),
280
+ re.compile(r"<\s*script", re.I),
281
+ re.compile(r"on\w+\s*=", re.I),
282
+ re.compile(r"\.\.[/\\]"),
283
+ re.compile(r"\$\{jndi", re.I),
284
+ re.compile(r";\s*(cat|ls|curl|wget|nc|bash|sh)\b", re.I),
285
+ re.compile(r"(ignore|disregard)\s+(all\s+)?(previous|prior)", re.I),
286
+ re.compile(r"169\.254\.169\.254|metadata\.google\.internal", re.I),
287
+ re.compile(r"--\s*$", re.M),
288
+ re.compile(r"%27|%3c\s*script", re.I),
289
+ ]
290
+
291
+
292
+ def snippet_around(text: str, max_bytes: int) -> str:
293
+ """
294
+ Keeps at most `max_bytes` of `text`, windowed around the first thing that
295
+ looks like the reason it was flagged (injection marker, traversal, SSRF
296
+ target...) so a capped payload still shows the analyst the interesting part
297
+ rather than its first N bytes. Falls back to the head when nothing stands out.
298
+ """
299
+ if _byte_len(text) <= max_bytes:
300
+ return text
301
+ at = -1
302
+ for pat in _TRIGGER_RES:
303
+ m = pat.search(text)
304
+ if m and (at == -1 or m.start() < at):
305
+ at = m.start()
306
+ if at <= max_bytes / 4:
307
+ return cap_text(text, max_bytes)
308
+ start = max(0, at - max_bytes // 4)
309
+ return f"\u2026{cap_text(text[start:], max_bytes - 3)}"
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: cybat-sdk
3
+ Version: 0.1.1
4
+ Summary: Cybat SDK for Python — WSGI/ASGI middleware that reports HTTP traffic directly to the Cybat Intelligence Engine
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://cybat.co
7
+ Project-URL: Documentation, https://cybat.co/#/docs
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # cybat-sdk (Python)
12
+
13
+ Flask, Django and ASGI (FastAPI / Starlette / any ASGI app) integration for the Cybat Intelligence Engine. Captures HTTP traffic your app already receives and reports it directly to Cybat — no GCP org-level log sink required.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install cybat-sdk
19
+ ```
20
+
21
+ No required dependencies — Flask/Django/etc. are assumed already installed by your project; only the integration module for the framework you actually use imports it.
22
+
23
+ ## Usage
24
+
25
+ ### Flask
26
+
27
+ ```python
28
+ from flask import Flask
29
+ from cybat_sdk.integrations.flask import init_app
30
+
31
+ app = Flask(__name__)
32
+ init_app(app,
33
+ api_key=os.environ["CYBAT_API_KEY"],
34
+ gcp_project_id="my-gcp-project", # enables automatic blocking — see below
35
+ service_name="checkout-api", # optional, just a label on incidents
36
+ )
37
+ ```
38
+
39
+ ### Django
40
+
41
+ Django instantiates middleware classes itself with no way to pass constructor kwargs, so configuration comes from `settings.py` instead of a function call:
42
+
43
+ ```python
44
+ # settings.py
45
+ MIDDLEWARE = [
46
+ # ... your other middleware ...
47
+ "cybat_sdk.integrations.django.CybatMiddleware",
48
+ ]
49
+ CYBAT_API_KEY = os.environ["CYBAT_API_KEY"]
50
+ CYBAT_GCP_PROJECT_ID = "my-gcp-project" # optional, enables automatic blocking
51
+ ```
52
+
53
+ Every option has a `CYBAT_*` settings equivalent — see [Options](#options) below for the full list; the Django setting name is `CYBAT_` + the option name upper-cased (`retry_backoff_ms` → `CYBAT_RETRY_BACKOFF_MS`).
54
+
55
+ ### FastAPI / Starlette / any ASGI app
56
+
57
+ ```python
58
+ from cybat_sdk.integrations.asgi import CybatASGIMiddleware
59
+
60
+ app.add_middleware(CybatASGIMiddleware,
61
+ api_key=os.environ["CYBAT_API_KEY"],
62
+ gcp_project_id="my-gcp-project",
63
+ )
64
+ ```
65
+
66
+ This speaks the raw ASGI protocol (`scope`/`receive`/`send`) directly rather than wrapping a framework-specific base class, so it works correctly with streaming responses too, and needs no FastAPI/Starlette import. `add_middleware(cls, **options)` instantiates `cls(app, **options)`, which is exactly this class's constructor — no adapter needed. For a bare ASGI app without that helper: `app = CybatASGIMiddleware(app, api_key=...)`.
67
+
68
+ Create an API key from the Intelligence Dashboard → **API Keys**. It's shown once at creation — store it as a secret (`CYBAT_API_KEY`), it can't be retrieved again.
69
+
70
+ ## Multi-cloud routing
71
+
72
+ Set **one** of `gcp_project_id` or `aws_project_id` — never both on the same SDK instance. Which one you set decides where data is sent, automatically:
73
+
74
+ | You set | Data goes to | Status |
75
+ |---------|--------------|--------|
76
+ | `gcp_project_id` (or neither) | `gcp-ingest.cybat.co` | Live |
77
+ | `aws_project_id` | `aws-ingest.cybat.co` | **Not yet implemented** — batches will fail to send until AWS-based ingestion ships |
78
+
79
+ An app that runs in both clouds needs two SDK instances, one per cloud — not one instance with both fields set (the constructor raises if you do that, since it's ambiguous which cloud's infrastructure should be enforced against).
80
+
81
+ ## ⚠️ Automatic blocking requires `gcp_project_id`
82
+
83
+ Cybat classifies and alerts on threats regardless of `gcp_project_id`. But **automatically blocking** a malicious IP (VPC Firewall / Cloud Armor) requires knowing which GCP project to act on — your app might not even be hosted on GCP, so there's no way to infer this from the traffic itself. If you omit `gcp_project_id`, threats are still detected and you still get alerted, but nothing gets blocked automatically.
84
+
85
+ To enable blocking:
86
+ 1. Set `gcp_project_id` to the GCP project your app's infrastructure lives in.
87
+ 2. Grant `soc-ai-agent@cybat-public.iam.gserviceaccount.com` the `roles/compute.securityAdmin` and `roles/compute.networkViewer` roles on that project (a Cloud Shell command for this is shown in the Intelligence Dashboard onboarding flow).
88
+
89
+ ## ⚠️ Behind a load balancer or reverse proxy?
90
+
91
+ Each integration resolves the client IP from the framework's own notion of it (Flask's `request.remote_addr`, Django's `request.META["REMOTE_ADDR"]`, the ASGI `scope["client"]`), which reflects the *direct* TCP peer. If your app sits behind your own load balancer, CDN, or reverse proxy, configure that framework's standard mechanism for trusting `X-Forwarded-For` — otherwise every request reports the proxy's IP instead of the real client, and Cybat won't be able to tell your traffic apart:
92
+
93
+ - **Flask**: wrap your WSGI app with Werkzeug's `ProxyFix`.
94
+ - **Django**: set `SECURE_PROXY_SSL_HEADER` / put a proxy-aware middleware before Cybat's in `MIDDLEWARE`, per Django's deployment docs.
95
+ - **ASGI**: use Starlette's/Uvicorn's `--proxy-headers --forwarded-allow-ips` (or `ProxyHeadersMiddleware`).
96
+
97
+ ## Options
98
+
99
+ All three integrations take the same options (Flask/ASGI as keyword arguments, Django as `CYBAT_*` settings):
100
+
101
+ ```python
102
+ {
103
+ "api_key": str, # required
104
+ "gcp_project_id": str, # enables automatic blocking + routes to GCP — see above
105
+ "aws_project_id": str, # routes to AWS — NOT YET LIVE, see "Multi-cloud routing"
106
+ "service_name": str, # optional label shown on incidents
107
+ "ingest_url": str, # overrides cloud auto-detection entirely; default depends on gcp_project_id/aws_project_id
108
+ "flush_interval_ms": 2000,
109
+ "max_batch_size": 100, # flushes immediately at this size
110
+ "max_retries": 2, # a batch is dropped, not queued forever, after this
111
+ "retry_backoff_ms": 500, # base delay before the first retry; doubles each retry (exponential)
112
+ "status_filter": "all", # "all" | "errors" | a callable (status: int) -> bool
113
+ "capture_body": False, # send a redacted excerpt of the request body — see "Request bodies & privacy"
114
+ "body_max_bytes": 2048, # default 2048, max 8192 — cap on the body excerpt after redaction
115
+ "body_status_filter": "all", # capture bodies only for matching responses
116
+ }
117
+ ```
118
+
119
+ `status_filter` controls which captured requests actually get sent:
120
+ - `"all"` (default) — every request, regardless of status code.
121
+ - `"errors"` — only 4xx/5xx. Use this to cut ingestion volume/cost on high-traffic apps where 2xx telemetry isn't needed — Cybat still classifies and alerts on threats using only error responses, the same signal the old GCP Log Sink integration filtered down to.
122
+ - A callable — full control, e.g. `lambda status: status != 304` to drop cache-hit noise but keep everything else, 2xx included.
123
+
124
+ ## Request bodies & privacy
125
+
126
+ Off by default. Set `capture_body=True` to include the request body in each event — that's where SQL/command injection, credential stuffing and prompt-injection against LLM-backed endpoints actually show up, so it meaningfully improves detection. It also means request content leaves your infrastructure, so **redaction runs in your process, before an event is even buffered, and can't be turned off** once capture is on:
127
+
128
+ | What | Becomes |
129
+ |------|---------|
130
+ | Emails | `j***n@example.com` |
131
+ | Card numbers (Luhn-checked, 13–19 digits) | `XXXX-XXXX-XXXX-1234` |
132
+ | JWTs, `Bearer …` / `Basic …` tokens | `[REDACTED jwt]`, `Bearer [REDACTED]` |
133
+ | Provider keys (AWS, Google, GitHub, Slack, Stripe) and PEM private keys | `[REDACTED key]` |
134
+ | The value of any field named like a secret (`password`, `token`, `secret`, `authorization`, `cookie`, `session`, `api_key`, `card`, `cvv`, `ssn`, `iban`…), in JSON, form bodies and query strings | `[REDACTED len=13 sus=quote,comment]` |
135
+
136
+ A redacted secret is replaced by a **content-free shape**, not blanked: its length plus which kinds of attack characters it contained (`quote`, `comment`, `sqlkw`, `angle`, `semicolon`, `traversal`, `cmdchar`, `template`, `nullbyte`). That's deliberate — if you blanked a `password` field entirely you'd also blank the SQL injection an attacker typed into it. With the shape, Cybat can still see "a 14-character password containing quote, comment and SQL-keyword characters" without ever seeing the value.
137
+
138
+ - JSON, form-encoded and text bodies only. Multipart, binary, and empty bodies are always skipped, regardless of `capture_body`.
139
+ - The excerpt is capped at `body_max_bytes` after redaction, centered on the most attack-looking part rather than just the first N bytes, and flagged `bodyTruncated` (on the wire) when it was cut.
140
+ - If redaction raises for any reason, no body is sent — never an unredacted one.
141
+ - `body_status_filter="errors"` captures bodies only for 4xx/5xx responses, which keeps volume (and what you send) down while still covering most attacks.
142
+
143
+ **URLs, referers and user agents are redacted the same way whether or not `capture_body` is on** — query strings routinely carry tokens and emails (`/reset?token=…&email=…`). Secret-named query values become the same shape, so `?password=' OR 1=1--` still reads as an injection attempt.
144
+
145
+ Redaction is pattern-based. It covers the common cases above but can't know that a field called `notes` happens to contain something private — if a route handles data you don't want to leave your infrastructure at all, leave `capture_body` off, or use `body_status_filter` / a `status_filter` callable to exclude it.
146
+
147
+ ## Behavior
148
+
149
+ - Never raises into your app. A network error, a rejected request, or a misconfigured key results in a dropped batch and a printed warning, not a crash.
150
+ - Buffers in memory (thread-safe) and flushes on a background daemon thread every `flush_interval_ms` — there's no persistent queue in the SDK itself. If the process exits between flushes, buffered events are lost (matches the durability model of application logs in general). `collector.stop()` stops the background thread on shutdown; it does not force a final flush — call `collector.flush()` first if you need one.
151
+ - Uses only the standard library (`urllib`) for the HTTP call — no extra dependency.
152
+ - One HTTP event captured per request: method, URL, status, remote IP, user agent, referer, latency, response size, timestamp (plus a redacted body excerpt if `capture_body` is on).
153
+ - A failed send retries `max_retries` times with exponential backoff (`retry_backoff_ms`, `retry_backoff_ms * 2`, `retry_backoff_ms * 4`, ...) before the batch is dropped for good. Retries are scheduled on a background timer thread and never block the request that triggered them.
154
+
155
+ ## Wire format
156
+
157
+ See [`../README.md`](../README.md) for the shared batch/auth contract every language SDK implements. Field names on the wire (`remoteIp`, `latencyMs`, `bodyTruncated`, ...) are camelCase per that shared contract even though this package's own Python API (`gcp_project_id`, `status_filter`, ...) is snake_case — that boundary is deliberate: the wire format is a cross-language contract, the options are this language's idiomatic API.
158
+
159
+ ## Publishing (maintainers)
160
+
161
+ CI is `.github/workflows/publish-python.yml`, using PyPI Trusted Publishing (OIDC) — no API token stored anywhere. One-time setup on [pypi.org](https://pypi.org) *before* the first release (PyPI lets you register a "pending publisher" for a project that doesn't exist yet):
162
+
163
+ 1. **pypi.org → Your projects → Publishing → Add a pending publisher**, with:
164
+ - PyPI project name: `cybat-sdk`
165
+ - Owner: `CybatInc`, Repository name: `SDK`, Workflow name: `publish-python.yml`, Environment name: `pypi`
166
+ 2. **GitHub repo → Settings → Environments → New environment**, name it `pypi` (must match exactly). Optionally add Required reviewers here for a manual approval gate before every publish — the OIDC equivalent of the 2FA-gated staging step `publish-node.yml` uses for npm.
167
+
168
+ To release:
169
+
170
+ ```bash
171
+ # bump "version" in pyproject.toml, commit that change, then tag THAT commit
172
+ git add pyproject.toml && git commit -m "chore: bump cybat-sdk to 0.1.1"
173
+ git tag 0.1.1
174
+ git push origin main --tags
175
+ ```
176
+
177
+ Same bare `X.Y.Z` tag format as `@cybat/sdk-node`'s releases — what keeps one tag from triggering both workflows is each one's `paths` filter (`python/**` here, `node/**` for Node's), which only matches if the tagged commit itself touched that package's directory. Tag the version-bump commit itself, not an unrelated one, or the path filter won't match and nothing will publish. The workflow runs the full test suite before building, then fails the build outright if the tag doesn't match `pyproject.toml`'s version.
@@ -0,0 +1,11 @@
1
+ cybat_sdk/__init__.py,sha256=5WZC-pE7TCY4R6OzMnJgUv_80ciRGe4ghn9fk6KZB0Y,169
2
+ cybat_sdk/collector.py,sha256=5OF5CCCx1nZ5vctL_tfP6ARKY3oLqF9ERenD43vU2JM,9205
3
+ cybat_sdk/redact.py,sha256=YXTWty_1U3VM6RW7cy-atA2oGipwnV0syNoK5MkWIW4,12245
4
+ cybat_sdk/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cybat_sdk/integrations/asgi.py,sha256=1QheRTP1p7AjYcSSOs1D6zgZEtlnwjlLcfpjC6OX7mQ,5343
6
+ cybat_sdk/integrations/django.py,sha256=k1XdDa7Svo93oJmfbn4uui0rhDSvzwg6VJmw2gMQD3o,5553
7
+ cybat_sdk/integrations/flask.py,sha256=-LggPslXRXeiQZ3D8XNj4ntSArZOLxw9Et6xHFiC3xA,3574
8
+ cybat_sdk-0.1.1.dist-info/METADATA,sha256=u4IegJG9eBvtKbw6cnyiGZwp3MNLJdaR9xgRCqozK7Q,12638
9
+ cybat_sdk-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ cybat_sdk-0.1.1.dist-info/top_level.txt,sha256=XuJjX79AKcDAK081xI4GHjjkN2-7npz3YI3fIC9FsPg,10
11
+ cybat_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cybat_sdk