bubble-analysis 0.2.0__py3-none-any.whl → 0.3.4__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.
@@ -6,6 +6,8 @@ from libcst.metadata import MetadataWrapper, PositionProvider
6
6
  from bubble.enums import EntrypointKind, Framework
7
7
  from bubble.integrations.base import Entrypoint, GlobalHandler
8
8
 
9
+ HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
10
+
9
11
 
10
12
  class FlaskRouteVisitor(cst.CSTVisitor):
11
13
  """
@@ -157,21 +159,185 @@ class FlaskErrorHandlerVisitor(cst.CSTVisitor):
157
159
  return ""
158
160
 
159
161
 
162
+ class FlaskRESTfulVisitor(cst.CSTVisitor):
163
+ """
164
+ Detects Flask-RESTful Resource classes and add_resource() registrations.
165
+
166
+ Supports:
167
+ - api.add_resource(ResourceClass, "/path")
168
+ - api.add_resource(ResourceClass, "/path1", "/path2")
169
+ - Custom methods like api.add_org_resource()
170
+ """
171
+
172
+ METADATA_DEPENDENCIES = (PositionProvider,)
173
+
174
+ ADD_RESOURCE_METHODS = {"add_resource", "add_org_resource"}
175
+
176
+ def __init__(self, file_path: str) -> None:
177
+ self.file_path = file_path
178
+ self.entrypoints: list[Entrypoint] = []
179
+ self.resource_classes: dict[str, dict[str, int]] = {}
180
+ self.resource_registrations: list[tuple[str, list[str], int]] = []
181
+
182
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
183
+ methods_found: dict[str, int] = {}
184
+ for item in node.body.body:
185
+ if isinstance(item, cst.FunctionDef):
186
+ method_name = item.name.value.lower()
187
+ if method_name in HTTP_METHODS and not self._has_route_decorator(item):
188
+ pos = self.get_metadata(PositionProvider, item)
189
+ methods_found[method_name.upper()] = pos.start.line
190
+
191
+ if methods_found:
192
+ self.resource_classes[node.name.value] = methods_found
193
+
194
+ return True
195
+
196
+ def _has_route_decorator(self, node: cst.FunctionDef) -> bool:
197
+ """Check if a function has a route decorator like @expose, @route."""
198
+ for decorator in node.decorators:
199
+ dec = decorator.decorator
200
+ if isinstance(dec, cst.Call):
201
+ if isinstance(dec.func, cst.Attribute):
202
+ if dec.func.attr.value in ("route", "expose"):
203
+ return True
204
+ elif isinstance(dec.func, cst.Name):
205
+ if dec.func.value in ("route", "expose"):
206
+ return True
207
+ elif isinstance(dec, cst.Attribute):
208
+ if dec.attr.value in ("route", "expose"):
209
+ return True
210
+ elif isinstance(dec, cst.Name):
211
+ if dec.value in ("route", "expose"):
212
+ return True
213
+ return False
214
+
215
+ def visit_Call(self, node: cst.Call) -> bool:
216
+ if not isinstance(node.func, cst.Attribute):
217
+ return True
218
+
219
+ method_name = node.func.attr.value
220
+ if method_name not in self.ADD_RESOURCE_METHODS:
221
+ return True
222
+
223
+ if len(node.args) < 2:
224
+ return True
225
+
226
+ first_arg = node.args[0].value
227
+ resource_name = self._get_name_from_expr(first_arg)
228
+ if not resource_name:
229
+ return True
230
+
231
+ urls: list[str] = []
232
+ for arg in node.args[1:]:
233
+ if arg.keyword is not None:
234
+ continue
235
+ url = self._extract_string(arg.value)
236
+ if url:
237
+ urls.append(url)
238
+
239
+ if urls:
240
+ pos = self.get_metadata(PositionProvider, node)
241
+ self.resource_registrations.append((resource_name, urls, pos.start.line))
242
+
243
+ return True
244
+
245
+ def leave_Module(self, original_node: cst.Module) -> None:
246
+ registered_classes: set[str] = set()
247
+
248
+ for resource_name, urls, reg_line in self.resource_registrations:
249
+ registered_classes.add(resource_name)
250
+ methods = self.resource_classes.get(resource_name, {})
251
+ if not methods:
252
+ methods = {"GET": reg_line}
253
+
254
+ for url in urls:
255
+ for method, method_line in methods.items():
256
+ self.entrypoints.append(
257
+ Entrypoint(
258
+ file=self.file_path,
259
+ function=f"{resource_name}.{method.lower()}",
260
+ line=method_line,
261
+ kind=EntrypointKind.HTTP_ROUTE,
262
+ metadata={
263
+ "http_method": method,
264
+ "http_path": url,
265
+ "framework": Framework.FLASK,
266
+ "flask_restful": True,
267
+ },
268
+ )
269
+ )
270
+
271
+ for class_name, methods in self.resource_classes.items():
272
+ if class_name in registered_classes:
273
+ continue
274
+
275
+ for method, method_line in methods.items():
276
+ self.entrypoints.append(
277
+ Entrypoint(
278
+ file=self.file_path,
279
+ function=f"{class_name}.{method.lower()}",
280
+ line=method_line,
281
+ kind=EntrypointKind.HTTP_ROUTE,
282
+ metadata={
283
+ "http_method": method,
284
+ "http_path": f"<flask-restful:{class_name}>",
285
+ "framework": Framework.FLASK,
286
+ "flask_restful": True,
287
+ },
288
+ )
289
+ )
290
+
291
+ def _get_name_from_expr(self, expr: cst.BaseExpression) -> str:
292
+ if isinstance(expr, cst.Name):
293
+ return expr.value
294
+ elif isinstance(expr, cst.Attribute):
295
+ return expr.attr.value
296
+ return ""
297
+
298
+ def _extract_string(self, node: cst.BaseExpression) -> str | None:
299
+ if isinstance(node, cst.SimpleString):
300
+ return node.evaluated_value
301
+ elif isinstance(node, cst.ConcatenatedString):
302
+ parts = []
303
+ for part in (node.left, node.right):
304
+ if isinstance(part, cst.SimpleString):
305
+ val = part.evaluated_value
306
+ if val:
307
+ parts.append(val)
308
+ return "".join(parts) if parts else None
309
+ return None
310
+
311
+
160
312
  def detect_flask_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
161
- """Detect Flask route entrypoints in a Python source file."""
313
+ """Detect Flask route entrypoints in a Python source file.
314
+
315
+ Detects both decorator-based routes (@app.route) and
316
+ Flask-RESTful call-based routes (api.add_resource).
317
+ """
162
318
  try:
163
319
  module = cst.parse_module(source)
164
320
  except Exception:
165
321
  return []
166
322
 
323
+ entrypoints: list[Entrypoint] = []
167
324
  wrapper = MetadataWrapper(module)
168
- visitor = FlaskRouteVisitor(file_path)
169
325
 
326
+ route_visitor = FlaskRouteVisitor(file_path)
170
327
  try:
171
- wrapper.visit(visitor)
172
- return visitor.entrypoints
328
+ wrapper.visit(route_visitor)
329
+ entrypoints.extend(route_visitor.entrypoints)
173
330
  except Exception:
174
- return []
331
+ pass
332
+
333
+ restful_visitor = FlaskRESTfulVisitor(file_path)
334
+ try:
335
+ wrapper.visit(restful_visitor)
336
+ entrypoints.extend(restful_visitor.entrypoints)
337
+ except Exception:
338
+ pass
339
+
340
+ return entrypoints
175
341
 
176
342
 
177
343
  def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHandler]:
@@ -189,3 +355,75 @@ def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHand
189
355
  return visitor.handlers
190
356
  except Exception:
191
357
  return []
358
+
359
+
360
+ def correlate_flask_restful_entrypoints(entrypoints: list[Entrypoint]) -> list[Entrypoint]:
361
+ """Correlate Flask-RESTful entrypoints across files.
362
+
363
+ Flask-RESTful apps often define Resource classes in one file and register
364
+ them with api.add_resource() in another. This function merges information
365
+ from both sources:
366
+ - Class definitions have correct methods but placeholder paths
367
+ - Registrations have correct paths but fallback methods
368
+
369
+ Same-file cases (already fully resolved) are passed through unchanged.
370
+ Only placeholder entries trigger cross-file correlation.
371
+
372
+ Returns a new list with correlated entrypoints.
373
+ """
374
+ placeholder_classes: dict[str, list[Entrypoint]] = {}
375
+ real_path_entries: dict[str, list[Entrypoint]] = {}
376
+ non_flask_restful: list[Entrypoint] = []
377
+
378
+ for ep in entrypoints:
379
+ if not ep.metadata.get("flask_restful"):
380
+ non_flask_restful.append(ep)
381
+ continue
382
+
383
+ path = ep.metadata.get("http_path", "")
384
+
385
+ if path.startswith("<flask-restful:") and path.endswith(">"):
386
+ class_name = path[15:-1]
387
+ if class_name not in placeholder_classes:
388
+ placeholder_classes[class_name] = []
389
+ placeholder_classes[class_name].append(ep)
390
+ else:
391
+ parts = ep.function.rsplit(".", 1)
392
+ if len(parts) == 2:
393
+ class_name = parts[0]
394
+ if class_name not in real_path_entries:
395
+ real_path_entries[class_name] = []
396
+ real_path_entries[class_name].append(ep)
397
+ else:
398
+ non_flask_restful.append(ep)
399
+
400
+ result: list[Entrypoint] = list(non_flask_restful)
401
+
402
+ for class_name, class_eps in placeholder_classes.items():
403
+ if class_name in real_path_entries:
404
+ reg_eps = real_path_entries[class_name]
405
+ paths_from_registrations = list({ep.metadata.get("http_path") for ep in reg_eps})
406
+
407
+ for class_ep in class_eps:
408
+ for reg_path in paths_from_registrations:
409
+ result.append(
410
+ Entrypoint(
411
+ file=class_ep.file,
412
+ function=class_ep.function,
413
+ line=class_ep.line,
414
+ kind=class_ep.kind,
415
+ metadata={
416
+ **class_ep.metadata,
417
+ "http_path": reg_path,
418
+ },
419
+ )
420
+ )
421
+
422
+ del real_path_entries[class_name]
423
+ else:
424
+ result.extend(class_eps)
425
+
426
+ for class_name, eps in real_path_entries.items():
427
+ result.extend(eps)
428
+
429
+ return result
@@ -53,6 +53,13 @@ def audit(
53
53
  ]
54
54
  for exc_type, raises_list in issue.caught_by_generic.items()
55
55
  },
56
+ "caught_by_remote": {
57
+ exc_type: [
58
+ {"file": r.file, "line": r.line, "function": r.function}
59
+ for r in raises_list
60
+ ]
61
+ for exc_type, raises_list in issue.caught_by_remote.items()
62
+ },
56
63
  }
57
64
  for issue in result.issues
58
65
  ],
@@ -22,11 +22,19 @@ class IntegrationData:
22
22
 
23
23
  @dataclass
24
24
  class AuditIssue:
25
- """An entrypoint with uncaught or poorly-handled exceptions."""
25
+ """An entrypoint with uncaught or poorly-handled exceptions.
26
+
27
+ Fields:
28
+ - uncaught: Exceptions with no handler at all
29
+ - caught_by_generic: Caught by generic handler (@errorhandler(Exception))
30
+ - caught_by_remote: Caught by a handler in a different file (may indicate missing local handler)
31
+ - caught: Caught by a handler in the same file
32
+ """
26
33
 
27
34
  entrypoint: Entrypoint
28
35
  uncaught: dict[str, list["RaiseSite"]]
29
36
  caught_by_generic: dict[str, list["RaiseSite"]]
37
+ caught_by_remote: dict[str, list["RaiseSite"]]
30
38
  caught: dict[str, list["RaiseSite"]]
31
39
 
32
40
 
@@ -3,6 +3,8 @@
3
3
  Shared audit/entrypoint logic that all integrations use.
4
4
  """
5
5
 
6
+ from typing import TYPE_CHECKING
7
+
6
8
  from bubble.config import FlowConfig
7
9
  from bubble.integrations.base import Entrypoint, GlobalHandler, Integration
8
10
  from bubble.integrations.models import (
@@ -23,6 +25,9 @@ from bubble.propagation import (
23
25
  propagate_exceptions,
24
26
  )
25
27
 
28
+ if TYPE_CHECKING:
29
+ from bubble.stubs import StubLibrary
30
+
26
31
 
27
32
  def _filter_async_boundaries(
28
33
  forward_graph: dict[str, set[str]], config: FlowConfig
@@ -51,6 +56,7 @@ def _compute_exception_flow_for_integration(
51
56
  forward_graph: dict[str, set[str]] | None = None,
52
57
  name_to_qualified: dict[str, list[str]] | None = None,
53
58
  config: FlowConfig | None = None,
59
+ entrypoint_file: str | None = None,
54
60
  ) -> ExceptionFlow:
55
61
  """Compute exception flow for a function with integration-specific handling.
56
62
 
@@ -134,9 +140,17 @@ def _compute_exception_flow_for_integration(
134
140
  flow.caught_by_generic[exc_type] = []
135
141
  flow.caught_by_generic[exc_type].extend(raise_sites)
136
142
  else:
137
- if exc_type not in flow.caught_by_global:
138
- flow.caught_by_global[exc_type] = []
139
- flow.caught_by_global[exc_type].extend(raise_sites)
143
+ is_same_file = (
144
+ entrypoint_file is not None and caught_by_handler.file == entrypoint_file
145
+ )
146
+ if is_same_file:
147
+ if exc_type not in flow.caught_by_global:
148
+ flow.caught_by_global[exc_type] = []
149
+ flow.caught_by_global[exc_type].extend(raise_sites)
150
+ else:
151
+ if exc_type not in flow.caught_by_remote_global:
152
+ flow.caught_by_remote_global[exc_type] = []
153
+ flow.caught_by_remote_global[exc_type].extend(raise_sites)
140
154
  continue
141
155
 
142
156
  framework_response = integration.get_exception_response(exc_type)
@@ -181,6 +195,7 @@ def audit_integration(
181
195
  global_handlers: list[GlobalHandler],
182
196
  skip_evidence: bool = True,
183
197
  config: FlowConfig | None = None,
198
+ stub_library: "StubLibrary | None" = None,
184
199
  ) -> AuditResult:
185
200
  """Audit entrypoints for a specific integration.
186
201
 
@@ -188,6 +203,7 @@ def audit_integration(
188
203
  skip_evidence: Skip building evidence paths for faster auditing.
189
204
  Set to False if you need path details.
190
205
  config: Optional FlowConfig with handled_base_classes and async_boundaries.
206
+ stub_library: Optional stub library for external library exceptions.
191
207
  """
192
208
  if not entrypoints:
193
209
  return AuditResult(
@@ -197,7 +213,9 @@ def audit_integration(
197
213
  clean_count=0,
198
214
  )
199
215
 
200
- propagation = propagate_exceptions(model, skip_evidence=skip_evidence)
216
+ propagation = propagate_exceptions(
217
+ model, skip_evidence=skip_evidence, stub_library=stub_library
218
+ )
201
219
  reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
202
220
 
203
221
  forward_graph = build_forward_call_graph(model)
@@ -218,12 +236,16 @@ def audit_integration(
218
236
  forward_graph,
219
237
  name_to_qualified,
220
238
  config,
239
+ entrypoint_file=entrypoint.file,
221
240
  )
222
241
 
223
242
  real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
224
243
  real_generic = {
225
244
  k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
226
245
  }
246
+ real_remote = {
247
+ k: v for k, v in flow.caught_by_remote_global.items() if k not in reraise_patterns
248
+ }
227
249
 
228
250
  if real_uncaught or real_generic:
229
251
  issues.append(
@@ -231,6 +253,7 @@ def audit_integration(
231
253
  entrypoint=entrypoint,
232
254
  uncaught=real_uncaught,
233
255
  caught_by_generic=real_generic,
256
+ caught_by_remote=real_remote,
234
257
  caught=flow.caught_by_global,
235
258
  )
236
259
  )
@@ -399,10 +422,14 @@ def _trace_to_entrypoints(
399
422
  return
400
423
  visited.add(current)
401
424
 
402
- current_simple = current.split("::")[-1] if "::" in current else current
403
- current_simple = current_simple.split(".")[-1]
425
+ current_qualified = current.split("::")[-1] if "::" in current else current
426
+ current_simple = current_qualified.split(".")[-1]
404
427
 
405
- if current in entrypoint_functions or current_simple in entrypoint_functions:
428
+ if (
429
+ current in entrypoint_functions
430
+ or current_qualified in entrypoint_functions
431
+ or current_simple in entrypoint_functions
432
+ ):
406
433
  paths.append(list(path))
407
434
  return
408
435
 
@@ -411,13 +438,11 @@ def _trace_to_entrypoints(
411
438
  if len(paths) >= max_paths:
412
439
  return
413
440
  if reachable_from_entrypoints is not None:
414
- caller_simple = (
415
- caller.split("::")[-1].split(".")[-1]
416
- if "::" in caller
417
- else caller.split(".")[-1]
418
- )
441
+ caller_qualified = caller.split("::")[-1] if "::" in caller else caller
442
+ caller_simple = caller_qualified.split(".")[-1]
419
443
  if (
420
444
  caller not in reachable_from_entrypoints
445
+ and caller_qualified not in reachable_from_entrypoints
421
446
  and caller_simple not in reachable_from_entrypoints
422
447
  ):
423
448
  continue
@@ -460,7 +485,9 @@ def trace_routes_to_exception(
460
485
  endpoint = path[-1]
461
486
  entrypoints_reached.add(endpoint)
462
487
  if "::" in endpoint:
463
- entrypoints_reached.add(endpoint.split("::")[-1].split(".")[-1])
488
+ qualified_part = endpoint.split("::")[-1]
489
+ entrypoints_reached.add(qualified_part)
490
+ entrypoints_reached.add(qualified_part.split(".")[-1])
464
491
 
465
492
  matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
466
493