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 +5 -0
- cybat_sdk/collector.py +212 -0
- cybat_sdk/integrations/__init__.py +0 -0
- cybat_sdk/integrations/asgi.py +143 -0
- cybat_sdk/integrations/django.py +142 -0
- cybat_sdk/integrations/flask.py +102 -0
- cybat_sdk/redact.py +309 -0
- cybat_sdk-0.1.1.dist-info/METADATA +177 -0
- cybat_sdk-0.1.1.dist-info/RECORD +11 -0
- cybat_sdk-0.1.1.dist-info/WHEEL +5 -0
- cybat_sdk-0.1.1.dist-info/top_level.txt +1 -0
cybat_sdk/__init__.py
ADDED
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 @@
|
|
|
1
|
+
cybat_sdk
|