bubble-analysis 0.2.0__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.
- bubble/__init__.py +3 -0
- bubble/cache.py +207 -0
- bubble/cli.py +470 -0
- bubble/config.py +52 -0
- bubble/detectors.py +90 -0
- bubble/enums.py +65 -0
- bubble/extractor.py +829 -0
- bubble/formatters.py +887 -0
- bubble/integrations/__init__.py +92 -0
- bubble/integrations/base.py +98 -0
- bubble/integrations/cli_scripts/__init__.py +49 -0
- bubble/integrations/cli_scripts/cli.py +108 -0
- bubble/integrations/cli_scripts/detector.py +149 -0
- bubble/integrations/django/__init__.py +63 -0
- bubble/integrations/django/cli.py +111 -0
- bubble/integrations/django/detector.py +331 -0
- bubble/integrations/django/semantics.py +40 -0
- bubble/integrations/fastapi/__init__.py +57 -0
- bubble/integrations/fastapi/cli.py +110 -0
- bubble/integrations/fastapi/detector.py +176 -0
- bubble/integrations/fastapi/semantics.py +14 -0
- bubble/integrations/flask/__init__.py +57 -0
- bubble/integrations/flask/cli.py +110 -0
- bubble/integrations/flask/detector.py +191 -0
- bubble/integrations/flask/semantics.py +19 -0
- bubble/integrations/formatters.py +268 -0
- bubble/integrations/generic/__init__.py +13 -0
- bubble/integrations/generic/config.py +106 -0
- bubble/integrations/generic/detector.py +346 -0
- bubble/integrations/generic/frameworks.py +145 -0
- bubble/integrations/models.py +68 -0
- bubble/integrations/queries.py +481 -0
- bubble/loader.py +118 -0
- bubble/models.py +397 -0
- bubble/propagation.py +737 -0
- bubble/protocols.py +104 -0
- bubble/queries.py +627 -0
- bubble/results.py +211 -0
- bubble/stubs.py +89 -0
- bubble/timing.py +144 -0
- bubble_analysis-0.2.0.dist-info/METADATA +264 -0
- bubble_analysis-0.2.0.dist-info/RECORD +46 -0
- bubble_analysis-0.2.0.dist-info/WHEEL +5 -0
- bubble_analysis-0.2.0.dist-info/entry_points.txt +2 -0
- bubble_analysis-0.2.0.dist-info/licenses/LICENSE +21 -0
- bubble_analysis-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Query functions for integration-specific operations.
|
|
2
|
+
|
|
3
|
+
Shared audit/entrypoint logic that all integrations use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from bubble.config import FlowConfig
|
|
7
|
+
from bubble.integrations.base import Entrypoint, GlobalHandler, Integration
|
|
8
|
+
from bubble.integrations.models import (
|
|
9
|
+
AuditIssue,
|
|
10
|
+
AuditResult,
|
|
11
|
+
EntrypointsResult,
|
|
12
|
+
EntrypointTrace,
|
|
13
|
+
RoutesToResult,
|
|
14
|
+
)
|
|
15
|
+
from bubble.models import ProgramModel, RaiseSite
|
|
16
|
+
from bubble.propagation import (
|
|
17
|
+
ExceptionFlow,
|
|
18
|
+
PropagationResult,
|
|
19
|
+
build_forward_call_graph,
|
|
20
|
+
build_name_to_qualified,
|
|
21
|
+
build_reverse_call_graph,
|
|
22
|
+
compute_reachable_functions,
|
|
23
|
+
propagate_exceptions,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _filter_async_boundaries(
|
|
28
|
+
forward_graph: dict[str, set[str]], config: FlowConfig
|
|
29
|
+
) -> dict[str, set[str]]:
|
|
30
|
+
"""Remove calls that match async boundary patterns from the call graph.
|
|
31
|
+
|
|
32
|
+
Async boundaries (like Celery's .apply_async() or .delay()) spawn background
|
|
33
|
+
tasks where exceptions don't propagate back to the caller.
|
|
34
|
+
"""
|
|
35
|
+
filtered: dict[str, set[str]] = {}
|
|
36
|
+
for caller, callees in forward_graph.items():
|
|
37
|
+
filtered_callees = {
|
|
38
|
+
callee for callee in callees if not config.is_async_boundary(callee)
|
|
39
|
+
}
|
|
40
|
+
if filtered_callees:
|
|
41
|
+
filtered[caller] = filtered_callees
|
|
42
|
+
return filtered
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _compute_exception_flow_for_integration(
|
|
46
|
+
function_name: str,
|
|
47
|
+
model: ProgramModel,
|
|
48
|
+
propagation: PropagationResult,
|
|
49
|
+
integration: Integration,
|
|
50
|
+
global_handlers: list[GlobalHandler],
|
|
51
|
+
forward_graph: dict[str, set[str]] | None = None,
|
|
52
|
+
name_to_qualified: dict[str, list[str]] | None = None,
|
|
53
|
+
config: FlowConfig | None = None,
|
|
54
|
+
) -> ExceptionFlow:
|
|
55
|
+
"""Compute exception flow for a function with integration-specific handling.
|
|
56
|
+
|
|
57
|
+
Categorizes exceptions as:
|
|
58
|
+
- caught_locally: By try/except in the function
|
|
59
|
+
- caught_by_global: By global handler
|
|
60
|
+
- framework_handled: Converted to HTTP response (or handled by handled_base_classes)
|
|
61
|
+
- uncaught: Will escape
|
|
62
|
+
|
|
63
|
+
For better performance when calling repeatedly, pre-compute forward_graph and
|
|
64
|
+
name_to_qualified using build_forward_call_graph() and build_name_to_qualified().
|
|
65
|
+
"""
|
|
66
|
+
from bubble.models import ExceptionEvidence, compute_confidence
|
|
67
|
+
|
|
68
|
+
flow = ExceptionFlow()
|
|
69
|
+
handled_base_classes = config.handled_base_classes if config else []
|
|
70
|
+
|
|
71
|
+
func_key = None
|
|
72
|
+
for key in propagation.propagated_raises:
|
|
73
|
+
if key.endswith(f"::{function_name}") or key.endswith(f".{function_name}"):
|
|
74
|
+
func_key = key
|
|
75
|
+
break
|
|
76
|
+
if "::" in key and key.split("::")[-1].split(".")[-1] == function_name:
|
|
77
|
+
func_key = key
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
if func_key is None:
|
|
81
|
+
return flow
|
|
82
|
+
|
|
83
|
+
reachable = compute_reachable_functions(
|
|
84
|
+
func_key, model, propagation, forward_graph, name_to_qualified
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
escaping_exceptions = propagation.propagated_raises.get(func_key, set())
|
|
88
|
+
func_evidence = propagation.propagated_with_evidence.get(func_key, {})
|
|
89
|
+
|
|
90
|
+
global_handler_types: dict[str, GlobalHandler] = {}
|
|
91
|
+
for handler in global_handlers:
|
|
92
|
+
global_handler_types[handler.handled_type] = handler
|
|
93
|
+
global_handler_types[handler.handled_type.split(".")[-1]] = handler
|
|
94
|
+
|
|
95
|
+
for exc_type in escaping_exceptions:
|
|
96
|
+
exc_simple = exc_type.split(".")[-1]
|
|
97
|
+
|
|
98
|
+
raise_sites = [
|
|
99
|
+
r
|
|
100
|
+
for r in model.raise_sites
|
|
101
|
+
if (r.exception_type == exc_type or r.exception_type.split(".")[-1] == exc_simple)
|
|
102
|
+
and (r.function in reachable or f"{r.file}::{r.function}" in reachable)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
evidence_list: list[ExceptionEvidence] = []
|
|
106
|
+
for key, prop_raise in func_evidence.items():
|
|
107
|
+
if key[0] == exc_type:
|
|
108
|
+
evidence_list.append(
|
|
109
|
+
ExceptionEvidence(
|
|
110
|
+
raise_site=prop_raise.raise_site,
|
|
111
|
+
call_path=list(prop_raise.path),
|
|
112
|
+
confidence=compute_confidence(list(prop_raise.path)),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if evidence_list:
|
|
117
|
+
if exc_type not in flow.evidence:
|
|
118
|
+
flow.evidence[exc_type] = []
|
|
119
|
+
flow.evidence[exc_type].extend(evidence_list)
|
|
120
|
+
|
|
121
|
+
caught_by_handler = None
|
|
122
|
+
for handler_type, handler in global_handler_types.items():
|
|
123
|
+
handler_simple = handler_type.split(".")[-1]
|
|
124
|
+
if exc_simple == handler_simple:
|
|
125
|
+
caught_by_handler = handler
|
|
126
|
+
break
|
|
127
|
+
if model.exception_hierarchy.is_subclass_of(exc_simple, handler_simple):
|
|
128
|
+
caught_by_handler = handler
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
if caught_by_handler:
|
|
132
|
+
if caught_by_handler.is_generic:
|
|
133
|
+
if exc_type not in flow.caught_by_generic:
|
|
134
|
+
flow.caught_by_generic[exc_type] = []
|
|
135
|
+
flow.caught_by_generic[exc_type].extend(raise_sites)
|
|
136
|
+
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)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
framework_response = integration.get_exception_response(exc_type)
|
|
143
|
+
if framework_response:
|
|
144
|
+
if exc_type not in flow.framework_handled:
|
|
145
|
+
flow.framework_handled[exc_type] = []
|
|
146
|
+
for rs in raise_sites:
|
|
147
|
+
flow.framework_handled[exc_type].append((rs, framework_response))
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
is_handled_by_config = False
|
|
151
|
+
for base_class in handled_base_classes:
|
|
152
|
+
base_simple = base_class.split(".")[-1]
|
|
153
|
+
if exc_simple == base_simple or exc_type == base_class:
|
|
154
|
+
is_handled_by_config = True
|
|
155
|
+
break
|
|
156
|
+
if model.exception_hierarchy.is_subclass_of(exc_simple, base_simple):
|
|
157
|
+
is_handled_by_config = True
|
|
158
|
+
break
|
|
159
|
+
if model.exception_hierarchy.is_subclass_of(exc_type, base_class):
|
|
160
|
+
is_handled_by_config = True
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
if is_handled_by_config:
|
|
164
|
+
if exc_type not in flow.framework_handled:
|
|
165
|
+
flow.framework_handled[exc_type] = []
|
|
166
|
+
for rs in raise_sites:
|
|
167
|
+
flow.framework_handled[exc_type].append((rs, "handled by config"))
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if exc_type not in flow.uncaught:
|
|
171
|
+
flow.uncaught[exc_type] = []
|
|
172
|
+
flow.uncaught[exc_type].extend(raise_sites)
|
|
173
|
+
|
|
174
|
+
return flow
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def audit_integration(
|
|
178
|
+
model: ProgramModel,
|
|
179
|
+
integration: Integration,
|
|
180
|
+
entrypoints: list[Entrypoint],
|
|
181
|
+
global_handlers: list[GlobalHandler],
|
|
182
|
+
skip_evidence: bool = True,
|
|
183
|
+
config: FlowConfig | None = None,
|
|
184
|
+
) -> AuditResult:
|
|
185
|
+
"""Audit entrypoints for a specific integration.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
skip_evidence: Skip building evidence paths for faster auditing.
|
|
189
|
+
Set to False if you need path details.
|
|
190
|
+
config: Optional FlowConfig with handled_base_classes and async_boundaries.
|
|
191
|
+
"""
|
|
192
|
+
if not entrypoints:
|
|
193
|
+
return AuditResult(
|
|
194
|
+
integration_name=integration.name,
|
|
195
|
+
total_entrypoints=0,
|
|
196
|
+
issues=[],
|
|
197
|
+
clean_count=0,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
propagation = propagate_exceptions(model, skip_evidence=skip_evidence)
|
|
201
|
+
reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
|
|
202
|
+
|
|
203
|
+
forward_graph = build_forward_call_graph(model)
|
|
204
|
+
if config and config.async_boundaries:
|
|
205
|
+
forward_graph = _filter_async_boundaries(forward_graph, config)
|
|
206
|
+
name_to_qualified = build_name_to_qualified(propagation)
|
|
207
|
+
|
|
208
|
+
issues: list[AuditIssue] = []
|
|
209
|
+
clean_count = 0
|
|
210
|
+
|
|
211
|
+
for entrypoint in entrypoints:
|
|
212
|
+
flow = _compute_exception_flow_for_integration(
|
|
213
|
+
entrypoint.function,
|
|
214
|
+
model,
|
|
215
|
+
propagation,
|
|
216
|
+
integration,
|
|
217
|
+
global_handlers,
|
|
218
|
+
forward_graph,
|
|
219
|
+
name_to_qualified,
|
|
220
|
+
config,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
|
|
224
|
+
real_generic = {
|
|
225
|
+
k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if real_uncaught or real_generic:
|
|
229
|
+
issues.append(
|
|
230
|
+
AuditIssue(
|
|
231
|
+
entrypoint=entrypoint,
|
|
232
|
+
uncaught=real_uncaught,
|
|
233
|
+
caught_by_generic=real_generic,
|
|
234
|
+
caught=flow.caught_by_global,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
clean_count += 1
|
|
239
|
+
|
|
240
|
+
return AuditResult(
|
|
241
|
+
integration_name=integration.name,
|
|
242
|
+
total_entrypoints=len(entrypoints),
|
|
243
|
+
issues=issues,
|
|
244
|
+
clean_count=clean_count,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def list_integration_entrypoints(
|
|
249
|
+
integration: Integration,
|
|
250
|
+
entrypoints: list[Entrypoint],
|
|
251
|
+
) -> EntrypointsResult:
|
|
252
|
+
"""List entrypoints for a specific integration."""
|
|
253
|
+
return EntrypointsResult(
|
|
254
|
+
integration_name=integration.name,
|
|
255
|
+
entrypoints=entrypoints,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _find_raises(
|
|
260
|
+
model: ProgramModel,
|
|
261
|
+
exception_type: str,
|
|
262
|
+
include_subclasses: bool,
|
|
263
|
+
) -> tuple[set[str], list[RaiseSite]]:
|
|
264
|
+
"""Find raise sites matching an exception type. Returns (types_searched, matches)."""
|
|
265
|
+
types_to_find: set[str] = {exception_type}
|
|
266
|
+
if include_subclasses:
|
|
267
|
+
subclasses = model.exception_hierarchy.get_subclasses(exception_type)
|
|
268
|
+
types_to_find.update(subclasses)
|
|
269
|
+
|
|
270
|
+
matching_raises = [
|
|
271
|
+
r
|
|
272
|
+
for r in model.raise_sites
|
|
273
|
+
if r.exception_type in types_to_find
|
|
274
|
+
or r.exception_type.endswith(f".{exception_type}")
|
|
275
|
+
or any(r.exception_type.endswith(f".{t}") for t in types_to_find)
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
for t in list(types_to_find):
|
|
279
|
+
for r in model.raise_sites:
|
|
280
|
+
simple_name = r.exception_type.split(".")[-1]
|
|
281
|
+
if simple_name == t and r not in matching_raises:
|
|
282
|
+
matching_raises.append(r)
|
|
283
|
+
|
|
284
|
+
return types_to_find, matching_raises
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _get_callers_from_graphs(
|
|
288
|
+
function_name: str,
|
|
289
|
+
qualified_graph: dict[str, set[str]],
|
|
290
|
+
name_graph: dict[str, set[str]],
|
|
291
|
+
) -> set[str]:
|
|
292
|
+
"""Get callers using qualified graph first, falling back to name graph."""
|
|
293
|
+
callers = qualified_graph.get(function_name, set())
|
|
294
|
+
if not callers:
|
|
295
|
+
simple_name = (
|
|
296
|
+
function_name.split("::")[-1].split(".")[-1] if "::" in function_name else function_name
|
|
297
|
+
)
|
|
298
|
+
callers = name_graph.get(simple_name, set())
|
|
299
|
+
return callers
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _compute_entrypoint_reachability(
|
|
303
|
+
qualified_graph: dict[str, set[str]],
|
|
304
|
+
name_graph: dict[str, set[str]],
|
|
305
|
+
entrypoint_functions: set[str],
|
|
306
|
+
) -> tuple[set[str], dict[str, str]]:
|
|
307
|
+
"""
|
|
308
|
+
Compute which functions are reachable from entrypoints via forward BFS.
|
|
309
|
+
|
|
310
|
+
A function is reachable if an entrypoint calls it (directly or transitively).
|
|
311
|
+
This is used to prune the search space when tracing from raise sites back
|
|
312
|
+
to entrypoints - we only explore functions that could possibly connect.
|
|
313
|
+
|
|
314
|
+
Returns (reachable_set, function_to_entrypoint_map).
|
|
315
|
+
"""
|
|
316
|
+
reachable: set[str] = set()
|
|
317
|
+
func_to_entrypoint: dict[str, str] = {}
|
|
318
|
+
|
|
319
|
+
for ep in entrypoint_functions:
|
|
320
|
+
reachable.add(ep)
|
|
321
|
+
func_to_entrypoint[ep] = ep
|
|
322
|
+
|
|
323
|
+
forward_graph: dict[str, set[str]] = {}
|
|
324
|
+
for callee, callers in qualified_graph.items():
|
|
325
|
+
for caller in callers:
|
|
326
|
+
if caller not in forward_graph:
|
|
327
|
+
forward_graph[caller] = set()
|
|
328
|
+
forward_graph[caller].add(callee)
|
|
329
|
+
for callee, callers in name_graph.items():
|
|
330
|
+
for caller in callers:
|
|
331
|
+
if caller not in forward_graph:
|
|
332
|
+
forward_graph[caller] = set()
|
|
333
|
+
forward_graph[caller].add(callee)
|
|
334
|
+
|
|
335
|
+
simple_to_qualified: dict[str, list[str]] = {}
|
|
336
|
+
for key in forward_graph:
|
|
337
|
+
simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
338
|
+
if simple not in simple_to_qualified:
|
|
339
|
+
simple_to_qualified[simple] = []
|
|
340
|
+
simple_to_qualified[simple].append(key)
|
|
341
|
+
|
|
342
|
+
worklist = list(entrypoint_functions)
|
|
343
|
+
iterations = 0
|
|
344
|
+
max_iterations = 10000
|
|
345
|
+
|
|
346
|
+
while worklist and iterations < max_iterations:
|
|
347
|
+
iterations += 1
|
|
348
|
+
func = worklist.pop()
|
|
349
|
+
|
|
350
|
+
func_simple = func.split("::")[-1].split(".")[-1] if "::" in func else func.split(".")[-1]
|
|
351
|
+
|
|
352
|
+
callees: set[str] = set()
|
|
353
|
+
callees.update(forward_graph.get(func, set()))
|
|
354
|
+
for qualified_key in simple_to_qualified.get(func_simple, []):
|
|
355
|
+
callees.update(forward_graph.get(qualified_key, set()))
|
|
356
|
+
|
|
357
|
+
for callee in callees:
|
|
358
|
+
callee_simple = (
|
|
359
|
+
callee.split("::")[-1].split(".")[-1] if "::" in callee else callee.split(".")[-1]
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if callee not in reachable:
|
|
363
|
+
reachable.add(callee)
|
|
364
|
+
reachable.add(callee_simple)
|
|
365
|
+
if func in func_to_entrypoint:
|
|
366
|
+
func_to_entrypoint[callee] = func_to_entrypoint[func]
|
|
367
|
+
worklist.append(callee)
|
|
368
|
+
|
|
369
|
+
if callee_simple not in reachable:
|
|
370
|
+
reachable.add(callee_simple)
|
|
371
|
+
worklist.append(callee_simple)
|
|
372
|
+
|
|
373
|
+
return reachable, func_to_entrypoint
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _trace_to_entrypoints(
|
|
377
|
+
function_name: str,
|
|
378
|
+
qualified_graph: dict[str, set[str]],
|
|
379
|
+
name_graph: dict[str, set[str]],
|
|
380
|
+
entrypoint_functions: set[str],
|
|
381
|
+
reachable_from_entrypoints: set[str] | None = None,
|
|
382
|
+
max_depth: int = 20,
|
|
383
|
+
max_paths: int = 150,
|
|
384
|
+
) -> list[list[str]]:
|
|
385
|
+
"""
|
|
386
|
+
Trace call paths from function to entrypoints.
|
|
387
|
+
|
|
388
|
+
Uses reachability pruning: only explores branches that can reach entrypoints.
|
|
389
|
+
Limits to max_paths to avoid exponential blowup.
|
|
390
|
+
"""
|
|
391
|
+
paths: list[list[str]] = []
|
|
392
|
+
|
|
393
|
+
def dfs(current: str, path: list[str], visited: set[str]) -> None:
|
|
394
|
+
if len(paths) >= max_paths:
|
|
395
|
+
return
|
|
396
|
+
if len(path) > max_depth:
|
|
397
|
+
return
|
|
398
|
+
if current in visited:
|
|
399
|
+
return
|
|
400
|
+
visited.add(current)
|
|
401
|
+
|
|
402
|
+
current_simple = current.split("::")[-1] if "::" in current else current
|
|
403
|
+
current_simple = current_simple.split(".")[-1]
|
|
404
|
+
|
|
405
|
+
if current in entrypoint_functions or current_simple in entrypoint_functions:
|
|
406
|
+
paths.append(list(path))
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
callers = _get_callers_from_graphs(current, qualified_graph, name_graph)
|
|
410
|
+
for caller in callers:
|
|
411
|
+
if len(paths) >= max_paths:
|
|
412
|
+
return
|
|
413
|
+
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
|
+
)
|
|
419
|
+
if (
|
|
420
|
+
caller not in reachable_from_entrypoints
|
|
421
|
+
and caller_simple not in reachable_from_entrypoints
|
|
422
|
+
):
|
|
423
|
+
continue
|
|
424
|
+
dfs(caller, path + [caller], visited.copy())
|
|
425
|
+
|
|
426
|
+
dfs(function_name, [function_name], set())
|
|
427
|
+
return paths
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def trace_routes_to_exception(
|
|
431
|
+
model: ProgramModel,
|
|
432
|
+
integration: Integration,
|
|
433
|
+
entrypoints: list[Entrypoint],
|
|
434
|
+
exception_type: str,
|
|
435
|
+
include_subclasses: bool = False,
|
|
436
|
+
) -> RoutesToResult:
|
|
437
|
+
"""Trace which routes can reach an exception for a specific integration."""
|
|
438
|
+
types_searched, raise_sites = _find_raises(model, exception_type, include_subclasses)
|
|
439
|
+
|
|
440
|
+
qualified_graph, name_graph = build_reverse_call_graph(model)
|
|
441
|
+
entrypoint_functions = {e.function for e in entrypoints}
|
|
442
|
+
|
|
443
|
+
reachable, _ = _compute_entrypoint_reachability(
|
|
444
|
+
qualified_graph, name_graph, entrypoint_functions
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
traces: list[EntrypointTrace] = []
|
|
448
|
+
for raise_site in raise_sites:
|
|
449
|
+
qualified_function = f"{raise_site.file}::{raise_site.function}"
|
|
450
|
+
paths = _trace_to_entrypoints(
|
|
451
|
+
qualified_function,
|
|
452
|
+
qualified_graph,
|
|
453
|
+
name_graph,
|
|
454
|
+
entrypoint_functions,
|
|
455
|
+
reachable_from_entrypoints=reachable,
|
|
456
|
+
)
|
|
457
|
+
entrypoints_reached: set[str] = set()
|
|
458
|
+
for path in paths:
|
|
459
|
+
if path:
|
|
460
|
+
endpoint = path[-1]
|
|
461
|
+
entrypoints_reached.add(endpoint)
|
|
462
|
+
if "::" in endpoint:
|
|
463
|
+
entrypoints_reached.add(endpoint.split("::")[-1].split(".")[-1])
|
|
464
|
+
|
|
465
|
+
matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
|
|
466
|
+
|
|
467
|
+
traces.append(
|
|
468
|
+
EntrypointTrace(
|
|
469
|
+
raise_site=raise_site,
|
|
470
|
+
paths=paths,
|
|
471
|
+
entrypoints=matching_entrypoints,
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
return RoutesToResult(
|
|
476
|
+
integration_name=integration.name,
|
|
477
|
+
exception_type=exception_type,
|
|
478
|
+
include_subclasses=include_subclasses,
|
|
479
|
+
types_searched=types_searched,
|
|
480
|
+
traces=traces,
|
|
481
|
+
)
|
bubble/loader.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Loader for custom detectors from .flow/detectors/."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bubble.models import Entrypoint, GlobalHandler
|
|
9
|
+
from bubble.protocols import EntrypointDetector, GlobalHandlerDetector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DetectorRegistry:
|
|
13
|
+
"""Registry of custom detectors loaded from .flow/detectors/."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.entrypoint_detectors: list[EntrypointDetector] = []
|
|
17
|
+
self.global_handler_detectors: list[GlobalHandlerDetector] = []
|
|
18
|
+
self._loaded_modules: dict[str, Any] = {}
|
|
19
|
+
|
|
20
|
+
def load_from_directory(self, flow_dir: Path) -> None:
|
|
21
|
+
"""Load all detectors from a .flow/detectors/ directory."""
|
|
22
|
+
detectors_dir = flow_dir / "detectors"
|
|
23
|
+
if not detectors_dir.exists():
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
for py_file in detectors_dir.glob("*.py"):
|
|
27
|
+
if py_file.name.startswith("_"):
|
|
28
|
+
continue
|
|
29
|
+
self._load_detector_file(py_file)
|
|
30
|
+
|
|
31
|
+
def _load_detector_file(self, file_path: Path) -> None:
|
|
32
|
+
"""Load detectors from a single Python file."""
|
|
33
|
+
module_name = f"flow_custom_detectors.{file_path.stem}"
|
|
34
|
+
|
|
35
|
+
if module_name in self._loaded_modules:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
39
|
+
if spec is None or spec.loader is None:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
sys.modules[module_name] = module
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Warning: Failed to load detector {file_path}: {e}")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
self._loaded_modules[module_name] = module
|
|
52
|
+
|
|
53
|
+
for name in dir(module):
|
|
54
|
+
if name.startswith("_"):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
obj = getattr(module, name)
|
|
58
|
+
if not isinstance(obj, type):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if self._is_entrypoint_detector(obj):
|
|
62
|
+
try:
|
|
63
|
+
instance = obj()
|
|
64
|
+
self.entrypoint_detectors.append(instance)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
if self._is_global_handler_detector(obj):
|
|
69
|
+
try:
|
|
70
|
+
instance = obj()
|
|
71
|
+
self.global_handler_detectors.append(instance)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def _is_entrypoint_detector(self, cls: type) -> bool:
|
|
76
|
+
"""Check if a class implements EntrypointDetector protocol."""
|
|
77
|
+
if cls.__name__ in ("EntrypointDetector", "GlobalHandlerDetector"):
|
|
78
|
+
return False
|
|
79
|
+
return hasattr(cls, "detect") and callable(cls.detect)
|
|
80
|
+
|
|
81
|
+
def _is_global_handler_detector(self, cls: type) -> bool:
|
|
82
|
+
"""Check if a class implements GlobalHandlerDetector protocol."""
|
|
83
|
+
if cls.__name__ in ("EntrypointDetector", "GlobalHandlerDetector"):
|
|
84
|
+
return False
|
|
85
|
+
return hasattr(cls, "detect") and callable(cls.detect)
|
|
86
|
+
|
|
87
|
+
def detect_entrypoints(self, source: str, file_path: str) -> list[Entrypoint]:
|
|
88
|
+
"""Run all custom entrypoint detectors on a source file."""
|
|
89
|
+
results: list[Entrypoint] = []
|
|
90
|
+
for detector in self.entrypoint_detectors:
|
|
91
|
+
try:
|
|
92
|
+
detected = detector.detect(source, file_path)
|
|
93
|
+
if detected:
|
|
94
|
+
results.extend(detected)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return results
|
|
98
|
+
|
|
99
|
+
def detect_global_handlers(self, source: str, file_path: str) -> list[GlobalHandler]:
|
|
100
|
+
"""Run all custom global handler detectors on a source file."""
|
|
101
|
+
results: list[GlobalHandler] = []
|
|
102
|
+
for detector in self.global_handler_detectors:
|
|
103
|
+
try:
|
|
104
|
+
detected = detector.detect(source, file_path)
|
|
105
|
+
if detected:
|
|
106
|
+
results.extend(detected)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_detectors(directory: Path) -> DetectorRegistry:
|
|
113
|
+
"""Load custom detectors from a project's .flow/ directory."""
|
|
114
|
+
registry = DetectorRegistry()
|
|
115
|
+
flow_dir = directory / ".flow"
|
|
116
|
+
if flow_dir.exists():
|
|
117
|
+
registry.load_from_directory(flow_dir)
|
|
118
|
+
return registry
|