zubbl-fastapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: zubbl-fastapi
3
+ Version: 0.1.0
4
+ Summary: Official FastAPI integration for the Zubbl Runtime Intelligence Platform
5
+ Author-email: Zubbl <support@zubbl.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://zubbl.com
8
+ Project-URL: Documentation, https://zubbl.com
9
+ Keywords: fastapi,security,runtime,application-security,observability,telemetry,behaviour,api-security,zubbl
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: fastapi>=0.110.0
24
+ Requires-Dist: zubbl-runtime>=0.1.0
25
+ Provides-Extra: test
26
+ Requires-Dist: httpx>=0.27.0; extra == "test"
27
+ Requires-Dist: pytest>=8.0.0; extra == "test"
28
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
29
+
30
+ # Zubbl FastAPI
31
+
32
+ The official FastAPI integration for the Zubbl Runtime Intelligence Platform.
33
+
34
+ Zubbl provides real-time runtime visibility, behavioural security and adaptive protection from inside your application.
35
+
36
+ ## Features
37
+
38
+ - 5 minute integration
39
+ - Automatic endpoint discovery
40
+ - Runtime request telemetry
41
+ - Behavioural security signals
42
+ - Runtime protection policies
43
+ - Live Runtime Viewer integration
44
+ - Runtime Guard heartbeat
45
+ - Zero application code changes beyond initial setup
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.10+
50
+ - FastAPI 0.110+
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ pip install zubbl-fastapi
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from fastapi import FastAPI
62
+ from zubbl_fastapi import Zubbl
63
+
64
+ app = FastAPI()
65
+
66
+ zubbl = Zubbl("YOUR_API_KEY")
67
+
68
+ zubbl.attach(
69
+ app,
70
+ identify=lambda request: request.state.user.id
71
+ )
72
+ ```
73
+
74
+ Start your application.
75
+
76
+ Your application will automatically appear inside the Zubbl Runtime Viewer.
77
+
78
+ ## Documentation
79
+
80
+ https://zubbl.com
81
+
82
+ ## Runtime Intelligence
83
+
84
+ Unlike traditional security tooling, Zubbl observes what happens after authentication.
85
+
86
+ It continuously builds runtime evidence, behavioural intelligence and protection decisions directly from application behaviour.
87
+
88
+ ---
89
+
90
+ Built by Zubbl.
@@ -0,0 +1,61 @@
1
+ # Zubbl FastAPI
2
+
3
+ The official FastAPI integration for the Zubbl Runtime Intelligence Platform.
4
+
5
+ Zubbl provides real-time runtime visibility, behavioural security and adaptive protection from inside your application.
6
+
7
+ ## Features
8
+
9
+ - 5 minute integration
10
+ - Automatic endpoint discovery
11
+ - Runtime request telemetry
12
+ - Behavioural security signals
13
+ - Runtime protection policies
14
+ - Live Runtime Viewer integration
15
+ - Runtime Guard heartbeat
16
+ - Zero application code changes beyond initial setup
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.10+
21
+ - FastAPI 0.110+
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install zubbl-fastapi
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```python
32
+ from fastapi import FastAPI
33
+ from zubbl_fastapi import Zubbl
34
+
35
+ app = FastAPI()
36
+
37
+ zubbl = Zubbl("YOUR_API_KEY")
38
+
39
+ zubbl.attach(
40
+ app,
41
+ identify=lambda request: request.state.user.id
42
+ )
43
+ ```
44
+
45
+ Start your application.
46
+
47
+ Your application will automatically appear inside the Zubbl Runtime Viewer.
48
+
49
+ ## Documentation
50
+
51
+ https://zubbl.com
52
+
53
+ ## Runtime Intelligence
54
+
55
+ Unlike traditional security tooling, Zubbl observes what happens after authentication.
56
+
57
+ It continuously builds runtime evidence, behavioural intelligence and protection decisions directly from application behaviour.
58
+
59
+ ---
60
+
61
+ Built by Zubbl.
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zubbl-fastapi"
7
+ version = "0.1.0"
8
+ description = "Official FastAPI integration for the Zubbl Runtime Intelligence Platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ authors = [
13
+ { name = "Zubbl", email = "support@zubbl.com" }
14
+ ]
15
+
16
+ license = "MIT"
17
+
18
+ keywords = [
19
+ "fastapi",
20
+ "security",
21
+ "runtime",
22
+ "application-security",
23
+ "observability",
24
+ "telemetry",
25
+ "behaviour",
26
+ "api-security",
27
+ "zubbl"
28
+ ]
29
+
30
+ classifiers = [
31
+ "Development Status :: 4 - Beta",
32
+ "Framework :: FastAPI",
33
+ "Framework :: AsyncIO",
34
+ "Intended Audience :: Developers",
35
+ "Programming Language :: Python :: 3",
36
+ "Programming Language :: Python :: 3.10",
37
+ "Programming Language :: Python :: 3.11",
38
+ "Programming Language :: Python :: 3.12",
39
+ "Topic :: Internet",
40
+ "Topic :: Security",
41
+ "Topic :: Software Development :: Libraries"
42
+ ]
43
+
44
+ dependencies = [
45
+ "fastapi>=0.110.0",
46
+ "zubbl-runtime>=0.1.0",
47
+ ]
48
+
49
+ [project.urls]
50
+ Homepage = "https://zubbl.com"
51
+ Documentation = "https://zubbl.com"
52
+ [project.optional-dependencies]
53
+ test = [
54
+ "httpx>=0.27.0",
55
+ "pytest>=8.0.0",
56
+ "pytest-asyncio>=0.23.0",
57
+ ]
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.pytest.ini_options]
63
+ asyncio_mode = "auto"
64
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ from .integration import (
2
+ BlockResponseHandler,
3
+ DEFAULT_EXCLUDED_PATHS,
4
+ IdentityResolver,
5
+ MissingIdentityMode,
6
+ Zubbl,
7
+ ZubblFastAPIError,
8
+ )
9
+ from .endpoints import (
10
+ RouteCatalogue,
11
+ RouteCatalogueDiff,
12
+ RouteDescriptor,
13
+ )
14
+ from .identity import (
15
+ IdentityResolutionError,
16
+ identify_from_state,
17
+ )
18
+ from .middleware import ZubblMiddleware
19
+ from .observation import RuntimeResponseObservation
20
+
21
+ __all__ = [
22
+ "BlockResponseHandler",
23
+ "IdentityResolutionError",
24
+ "identify_from_state",
25
+ "DEFAULT_EXCLUDED_PATHS",
26
+ "IdentityResolver",
27
+ "MissingIdentityMode",
28
+ "RouteDescriptor",
29
+ "RouteCatalogueDiff",
30
+ "RouteCatalogue",
31
+ "RuntimeResponseObservation",
32
+ "Zubbl",
33
+ "ZubblFastAPIError",
34
+ "ZubblMiddleware",
35
+ ]
36
+
37
+ __version__ = "0.1.0"
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.routing import APIRoute
10
+
11
+
12
+ def _normalise_path(value: str) -> str:
13
+ cleaned = value.strip()
14
+
15
+ if not cleaned:
16
+ raise ValueError("endpoint path must be a non-empty string")
17
+
18
+ if not cleaned.startswith("/"):
19
+ cleaned = f"/{cleaned}"
20
+
21
+ if len(cleaned) > 1:
22
+ cleaned = cleaned.rstrip("/")
23
+
24
+ return cleaned or "/"
25
+
26
+
27
+ def _clean_optional_string(value: Any) -> str | None:
28
+ if not isinstance(value, str):
29
+ return None
30
+
31
+ cleaned = value.strip()
32
+ return cleaned or None
33
+
34
+
35
+ @dataclass(frozen=True, slots=True, order=True)
36
+ class RouteDescriptor:
37
+ """
38
+ One stable application endpoint.
39
+
40
+ A FastAPI route supporting several methods becomes one descriptor per
41
+ method, giving every endpoint a deterministic path/method identity.
42
+ """
43
+
44
+ path: str
45
+ method: str
46
+ name: str
47
+ operation_id: str | None = None
48
+ tags: tuple[str, ...] = ()
49
+ include_in_schema: bool = True
50
+
51
+ def __post_init__(self) -> None:
52
+ path = _normalise_path(self.path)
53
+ method = self.method.strip().upper()
54
+ name = self.name.strip()
55
+
56
+ if not method:
57
+ raise ValueError("endpoint method must be a non-empty string")
58
+
59
+ if not name:
60
+ raise ValueError("endpoint name must be a non-empty string")
61
+
62
+ tags = tuple(
63
+ dict.fromkeys(
64
+ str(tag).strip()
65
+ for tag in self.tags
66
+ if str(tag).strip()
67
+ )
68
+ )
69
+
70
+ object.__setattr__(self, "path", path)
71
+ object.__setattr__(self, "method", method)
72
+ object.__setattr__(self, "name", name)
73
+ object.__setattr__(
74
+ self,
75
+ "operation_id",
76
+ _clean_optional_string(self.operation_id),
77
+ )
78
+ object.__setattr__(self, "tags", tags)
79
+
80
+ @property
81
+ def key(self) -> tuple[str, str]:
82
+ return self.path, self.method
83
+
84
+ @property
85
+ def identifier(self) -> str:
86
+ return f"{self.method} {self.path}"
87
+
88
+ def as_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "path": self.path,
91
+ "method": self.method,
92
+ "name": self.name,
93
+ "operation_id": self.operation_id,
94
+ "tags": list(self.tags),
95
+ "include_in_schema": self.include_in_schema,
96
+ }
97
+
98
+ @property
99
+ def fingerprint(self) -> str:
100
+ payload = json.dumps(
101
+ self.as_dict(),
102
+ sort_keys=True,
103
+ separators=(",", ":"),
104
+ ensure_ascii=False,
105
+ ).encode("utf-8")
106
+
107
+ return hashlib.sha256(payload).hexdigest()
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class RouteCatalogueDiff:
112
+ added: tuple[RouteDescriptor, ...]
113
+ removed: tuple[RouteDescriptor, ...]
114
+ changed: tuple[
115
+ tuple[RouteDescriptor, RouteDescriptor],
116
+ ...,
117
+ ]
118
+
119
+ @property
120
+ def has_changes(self) -> bool:
121
+ return bool(self.added or self.removed or self.changed)
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class RouteCatalogue:
126
+ routes: tuple[RouteDescriptor, ...]
127
+
128
+ def __post_init__(self) -> None:
129
+ ordered = tuple(
130
+ sorted(
131
+ self.routes,
132
+ key=lambda route: (
133
+ route.path,
134
+ route.method,
135
+ route.name,
136
+ ),
137
+ )
138
+ )
139
+
140
+ keys = [route.key for route in ordered]
141
+
142
+ if len(keys) != len(set(keys)):
143
+ raise ValueError(
144
+ "route catalogue contains duplicate path/method entries"
145
+ )
146
+
147
+ object.__setattr__(self, "routes", ordered)
148
+
149
+ @classmethod
150
+ def empty(cls) -> RouteCatalogue:
151
+ return cls(routes=())
152
+
153
+ @classmethod
154
+ def from_app(
155
+ cls,
156
+ app: FastAPI,
157
+ *,
158
+ include_hidden: bool = False,
159
+ ) -> RouteCatalogue:
160
+ descriptors: list[RouteDescriptor] = []
161
+
162
+ for route in app.routes:
163
+ if not isinstance(route, APIRoute):
164
+ continue
165
+
166
+ if not include_hidden and not route.include_in_schema:
167
+ continue
168
+
169
+ methods = sorted(route.methods or ())
170
+
171
+ for method in methods:
172
+ if method in {"HEAD", "OPTIONS"}:
173
+ continue
174
+
175
+ operation_id = (
176
+ route.operation_id
177
+ or getattr(route, "unique_id", None)
178
+ )
179
+
180
+ descriptors.append(
181
+ RouteDescriptor(
182
+ path=route.path,
183
+ method=method,
184
+ name=route.name,
185
+ operation_id=operation_id,
186
+ tags=tuple(route.tags or ()),
187
+ include_in_schema=route.include_in_schema,
188
+ )
189
+ )
190
+
191
+ return cls(routes=tuple(descriptors))
192
+
193
+ def __len__(self) -> int:
194
+ return len(self.routes)
195
+
196
+ def __iter__(self):
197
+ return iter(self.routes)
198
+
199
+ def lookup(
200
+ self,
201
+ path: str,
202
+ method: str,
203
+ ) -> RouteDescriptor | None:
204
+ key = (
205
+ _normalise_path(path),
206
+ method.strip().upper(),
207
+ )
208
+
209
+ for route in self.routes:
210
+ if route.key == key:
211
+ return route
212
+
213
+ return None
214
+
215
+ def lookup_by_name(
216
+ self,
217
+ name: str,
218
+ ) -> tuple[RouteDescriptor, ...]:
219
+ cleaned = name.strip()
220
+
221
+ return tuple(
222
+ route
223
+ for route in self.routes
224
+ if route.name == cleaned
225
+ )
226
+
227
+ def lookup_by_operation_id(
228
+ self,
229
+ operation_id: str,
230
+ ) -> tuple[RouteDescriptor, ...]:
231
+ cleaned = operation_id.strip()
232
+
233
+ return tuple(
234
+ route
235
+ for route in self.routes
236
+ if route.operation_id == cleaned
237
+ )
238
+
239
+ def as_list(self) -> list[dict[str, Any]]:
240
+ return [route.as_dict() for route in self.routes]
241
+
242
+ @property
243
+ def fingerprint(self) -> str:
244
+ payload = json.dumps(
245
+ self.as_list(),
246
+ sort_keys=True,
247
+ separators=(",", ":"),
248
+ ensure_ascii=False,
249
+ ).encode("utf-8")
250
+
251
+ return hashlib.sha256(payload).hexdigest()
252
+
253
+ def diff(
254
+ self,
255
+ previous: RouteCatalogue,
256
+ ) -> RouteCatalogueDiff:
257
+ current_by_key = {
258
+ route.key: route
259
+ for route in self.routes
260
+ }
261
+ previous_by_key = {
262
+ route.key: route
263
+ for route in previous.routes
264
+ }
265
+
266
+ added = tuple(
267
+ current_by_key[key]
268
+ for key in sorted(
269
+ current_by_key.keys() - previous_by_key.keys()
270
+ )
271
+ )
272
+ removed = tuple(
273
+ previous_by_key[key]
274
+ for key in sorted(
275
+ previous_by_key.keys() - current_by_key.keys()
276
+ )
277
+ )
278
+
279
+ changed: list[
280
+ tuple[RouteDescriptor, RouteDescriptor]
281
+ ] = []
282
+
283
+ for key in sorted(
284
+ current_by_key.keys() & previous_by_key.keys()
285
+ ):
286
+ old = previous_by_key[key]
287
+ new = current_by_key[key]
288
+
289
+ if old != new:
290
+ changed.append((old, new))
291
+
292
+ return RouteCatalogueDiff(
293
+ added=added,
294
+ removed=removed,
295
+ changed=tuple(changed),
296
+ )
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from starlette.requests import Request
7
+
8
+
9
+ class IdentityResolutionError(ValueError):
10
+ """Raised when an identity helper is configured incorrectly."""
11
+
12
+
13
+ def _resolve_attribute_path(
14
+ value: Any,
15
+ attribute_path: str,
16
+ ) -> Any:
17
+ current = value
18
+
19
+ for segment in attribute_path.split("."):
20
+ cleaned = segment.strip()
21
+
22
+ if not cleaned:
23
+ raise IdentityResolutionError(
24
+ "attribute_path contains an empty segment"
25
+ )
26
+
27
+ if current is None:
28
+ return None
29
+
30
+ if isinstance(current, dict):
31
+ current = current.get(cleaned)
32
+ else:
33
+ current = getattr(current, cleaned, None)
34
+
35
+ return current
36
+
37
+
38
+ def identify_from_state(
39
+ attribute_path: str,
40
+ ) -> Callable[[Request], Any]:
41
+ """
42
+ Build an identity resolver from request.state.
43
+
44
+ Examples:
45
+ identify_from_state("user.id")
46
+ identify_from_state("external_user_id")
47
+ """
48
+ if not isinstance(attribute_path, str) or not attribute_path.strip():
49
+ raise IdentityResolutionError(
50
+ "attribute_path must be a non-empty string"
51
+ )
52
+
53
+ cleaned_path = attribute_path.strip()
54
+
55
+ def resolver(request: Request) -> Any:
56
+ return _resolve_attribute_path(
57
+ request.state,
58
+ cleaned_path,
59
+ )
60
+
61
+ return resolver