bubble-analysis 0.2.0__py3-none-any.whl → 0.3.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.
Potentially problematic release.
This version of bubble-analysis might be problematic. Click here for more details.
- bubble/detectors.py +7 -4
- bubble/extractor.py +37 -10
- bubble/formatters.py +7 -1
- bubble/integrations/django/cli.py +42 -6
- bubble/integrations/django/detector.py +57 -12
- bubble/integrations/fastapi/cli.py +42 -6
- bubble/integrations/flask/__init__.py +4 -0
- bubble/integrations/flask/cli.py +42 -6
- bubble/integrations/flask/detector.py +243 -5
- bubble/integrations/formatters.py +18 -0
- bubble/integrations/models.py +9 -1
- bubble/integrations/queries.py +31 -13
- bubble/propagation.py +62 -5
- bubble/queries.py +95 -12
- bubble/results.py +1 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/METADATA +1 -1
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/RECORD +21 -21
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/WHEEL +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/entry_points.txt +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.0.dist-info}/top_level.txt +0 -0
bubble/detectors.py
CHANGED
|
@@ -13,6 +13,7 @@ from bubble.integrations.cli_scripts.detector import (
|
|
|
13
13
|
from bubble.integrations.django.detector import (
|
|
14
14
|
DjangoExceptionHandlerVisitor,
|
|
15
15
|
DjangoViewVisitor,
|
|
16
|
+
detect_django_entrypoints,
|
|
16
17
|
)
|
|
17
18
|
from bubble.integrations.django.semantics import (
|
|
18
19
|
EXCEPTION_RESPONSES as DJANGO_EXCEPTION_RESPONSES,
|
|
@@ -26,7 +27,9 @@ from bubble.integrations.fastapi.semantics import (
|
|
|
26
27
|
)
|
|
27
28
|
from bubble.integrations.flask.detector import (
|
|
28
29
|
FlaskErrorHandlerVisitor,
|
|
30
|
+
FlaskRESTfulVisitor,
|
|
29
31
|
FlaskRouteVisitor,
|
|
32
|
+
detect_flask_entrypoints,
|
|
30
33
|
)
|
|
31
34
|
from bubble.integrations.flask.semantics import (
|
|
32
35
|
EXCEPTION_RESPONSES as FLASK_EXCEPTION_RESPONSES,
|
|
@@ -53,13 +56,13 @@ FRAMEWORK_EXCEPTION_RESPONSES: dict[str, dict[str, str]] = {
|
|
|
53
56
|
def detect_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
|
|
54
57
|
"""Detect entrypoints in a Python source file (HTTP routes and CLI scripts).
|
|
55
58
|
|
|
56
|
-
Uses
|
|
57
|
-
plus CLI script detection.
|
|
59
|
+
Uses framework-specific detectors for Flask and Django (with HTTP method detection),
|
|
60
|
+
the generic detector for FastAPI, plus CLI script detection.
|
|
58
61
|
"""
|
|
59
62
|
entrypoints: list[Entrypoint] = []
|
|
60
|
-
entrypoints.extend(
|
|
63
|
+
entrypoints.extend(detect_flask_entrypoints(source, file_path))
|
|
61
64
|
entrypoints.extend(generic_detect_entrypoints(source, file_path, FASTAPI_CONFIG))
|
|
62
|
-
entrypoints.extend(
|
|
65
|
+
entrypoints.extend(detect_django_entrypoints(source, file_path))
|
|
63
66
|
entrypoints.extend(detect_cli_entrypoints(source, file_path))
|
|
64
67
|
return entrypoints
|
|
65
68
|
|
bubble/extractor.py
CHANGED
|
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
|
|
13
13
|
pass
|
|
14
14
|
|
|
15
15
|
from bubble.detectors import detect_entrypoints, detect_global_handlers
|
|
16
|
+
from bubble.integrations.flask import correlate_flask_restful_entrypoints
|
|
16
17
|
from bubble.enums import ResolutionKind
|
|
17
18
|
from bubble.loader import load_detectors
|
|
18
19
|
from bubble.models import (
|
|
@@ -440,13 +441,19 @@ class CodeExtractor(cst.CSTVisitor):
|
|
|
440
441
|
return True
|
|
441
442
|
|
|
442
443
|
def _get_current_qualified_name(self) -> str:
|
|
443
|
-
"""Get the fully qualified name of the current context.
|
|
444
|
-
|
|
444
|
+
"""Get the fully qualified name of the current context.
|
|
445
|
+
|
|
446
|
+
Returns format: file.py::Class.method (matches canonical format from CLAUDE.md)
|
|
447
|
+
"""
|
|
445
448
|
if self._class_stack:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
449
|
+
if self._function_stack:
|
|
450
|
+
qualified = ".".join(self._class_stack + [self._function_stack[-1]])
|
|
451
|
+
else:
|
|
452
|
+
qualified = ".".join(self._class_stack)
|
|
453
|
+
return f"{self.relative_path}::{qualified}"
|
|
454
|
+
elif self._function_stack:
|
|
455
|
+
return f"{self.relative_path}::{self._function_stack[-1]}"
|
|
456
|
+
return self.relative_path
|
|
450
457
|
|
|
451
458
|
def _extract_depends_calls(
|
|
452
459
|
self,
|
|
@@ -615,13 +622,14 @@ def extract_from_file(file_path: Path, relative_path: str | None = None) -> File
|
|
|
615
622
|
result.return_types = extractor.return_types
|
|
616
623
|
result.detected_frameworks = extractor.detected_frameworks
|
|
617
624
|
|
|
625
|
+
detection_path = relative_path if relative_path else str(file_path)
|
|
618
626
|
try:
|
|
619
|
-
result.entrypoints = detect_entrypoints(source,
|
|
627
|
+
result.entrypoints = detect_entrypoints(source, detection_path)
|
|
620
628
|
except Exception:
|
|
621
629
|
pass
|
|
622
630
|
|
|
623
631
|
try:
|
|
624
|
-
result.global_handlers = detect_global_handlers(source,
|
|
632
|
+
result.global_handlers = detect_global_handlers(source, detection_path)
|
|
625
633
|
except Exception:
|
|
626
634
|
pass
|
|
627
635
|
|
|
@@ -664,6 +672,10 @@ def _inject_drf_dispatch_calls(model: ProgramModel) -> None:
|
|
|
664
672
|
When a DRF view class is detected as an entrypoint, this creates CallSite
|
|
665
673
|
entries from the view class to each HTTP method handler (get, post, etc.)
|
|
666
674
|
that exists on the class.
|
|
675
|
+
|
|
676
|
+
Handles both:
|
|
677
|
+
- Class-level entrypoints: "UserAPIView" -> creates edges to get, post, etc.
|
|
678
|
+
- Method-level entrypoints: "UserAPIView.get" -> creates edge to that method
|
|
667
679
|
"""
|
|
668
680
|
drf_view_entrypoints = [
|
|
669
681
|
ep
|
|
@@ -671,18 +683,32 @@ def _inject_drf_dispatch_calls(model: ProgramModel) -> None:
|
|
|
671
683
|
if ep.metadata.get("framework") == "django" and ep.metadata.get("view_type") == "class"
|
|
672
684
|
]
|
|
673
685
|
|
|
686
|
+
seen_edges: set[tuple[str, str]] = set()
|
|
687
|
+
|
|
674
688
|
for entrypoint in drf_view_entrypoints:
|
|
675
|
-
|
|
689
|
+
view_function = entrypoint.function
|
|
676
690
|
view_file = entrypoint.file
|
|
677
691
|
view_line = entrypoint.line
|
|
678
692
|
|
|
693
|
+
if "." in view_function:
|
|
694
|
+
view_class, method_name = view_function.rsplit(".", 1)
|
|
695
|
+
methods_to_inject = [method_name] if method_name in DRF_DISPATCH_METHODS else []
|
|
696
|
+
else:
|
|
697
|
+
view_class = view_function
|
|
698
|
+
methods_to_inject = list(DRF_DISPATCH_METHODS)
|
|
699
|
+
|
|
679
700
|
for _func_key, func_def in model.functions.items():
|
|
680
701
|
if not func_def.is_method:
|
|
681
702
|
continue
|
|
682
703
|
if func_def.class_name != view_class:
|
|
683
704
|
continue
|
|
684
|
-
if func_def.name not in
|
|
705
|
+
if func_def.name not in methods_to_inject:
|
|
706
|
+
continue
|
|
707
|
+
|
|
708
|
+
edge_key = (view_class, func_def.name)
|
|
709
|
+
if edge_key in seen_edges:
|
|
685
710
|
continue
|
|
711
|
+
seen_edges.add(edge_key)
|
|
686
712
|
|
|
687
713
|
relative_file = view_file
|
|
688
714
|
if "/" in relative_file or "\\" in relative_file:
|
|
@@ -824,6 +850,7 @@ def extract_from_directory(
|
|
|
824
850
|
if cache:
|
|
825
851
|
cache.close()
|
|
826
852
|
|
|
853
|
+
model.entrypoints = correlate_flask_restful_entrypoints(model.entrypoints)
|
|
827
854
|
_inject_drf_dispatch_calls(model)
|
|
828
855
|
|
|
829
856
|
return model
|
bubble/formatters.py
CHANGED
|
@@ -694,7 +694,13 @@ def catches(
|
|
|
694
694
|
|
|
695
695
|
total = len(result.local_catches) + len(result.global_handlers)
|
|
696
696
|
if total == 0:
|
|
697
|
-
|
|
697
|
+
if result.raise_site_count == 0:
|
|
698
|
+
console.print(
|
|
699
|
+
f"[yellow]No catch sites found for {result.exception_type}[/yellow]\n"
|
|
700
|
+
f"[dim] (this exception is not raised anywhere in the codebase)[/dim]"
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
console.print(f"[yellow]No catch sites found for {result.exception_type}[/yellow]")
|
|
698
704
|
return
|
|
699
705
|
|
|
700
706
|
subclass_note = " (and subclasses)" if result.include_subclasses else ""
|
|
@@ -40,8 +40,28 @@ def _get_django_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, lis
|
|
|
40
40
|
return entrypoints, handlers
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _filter_entrypoints(
|
|
44
|
+
entrypoints: list, filter_arg: str | None, directory: Path
|
|
45
|
+
) -> list:
|
|
46
|
+
"""Filter entrypoints by file path or route path."""
|
|
47
|
+
if not filter_arg:
|
|
48
|
+
return entrypoints
|
|
49
|
+
|
|
50
|
+
if filter_arg.startswith("/"):
|
|
51
|
+
return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
|
|
52
|
+
|
|
53
|
+
filter_path = Path(filter_arg)
|
|
54
|
+
if not filter_path.is_absolute():
|
|
55
|
+
filter_path = directory / filter_path
|
|
56
|
+
|
|
57
|
+
return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
@app.command()
|
|
44
61
|
def audit(
|
|
62
|
+
filter_arg: Annotated[
|
|
63
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
|
|
64
|
+
] = None,
|
|
45
65
|
directory: Annotated[
|
|
46
66
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
47
67
|
] = Path("."),
|
|
@@ -50,22 +70,35 @@ def audit(
|
|
|
50
70
|
) -> None:
|
|
51
71
|
"""Check Django views for escaping exceptions.
|
|
52
72
|
|
|
53
|
-
Scans
|
|
73
|
+
Scans Django views (APIView, ViewSet, @api_view) and reports which have uncaught exceptions.
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
Examples:
|
|
76
|
+
bubble django audit # All views
|
|
77
|
+
bubble django audit /users # Views matching /users
|
|
78
|
+
bubble django audit views/users.py # Views in specific file
|
|
58
79
|
"""
|
|
59
80
|
directory = directory.resolve()
|
|
60
81
|
config = load_config(directory)
|
|
61
82
|
model = _build_model(directory, use_cache=not no_cache)
|
|
62
83
|
entrypoints, handlers = _get_django_entrypoints_and_handlers(model)
|
|
84
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
85
|
+
|
|
86
|
+
if filter_arg and not entrypoints:
|
|
87
|
+
if filter_arg.startswith("/"):
|
|
88
|
+
console.print(f"[yellow]No Django views found matching {filter_arg}[/yellow]")
|
|
89
|
+
else:
|
|
90
|
+
console.print(f"[yellow]No Django views found in {filter_arg}[/yellow]")
|
|
91
|
+
return
|
|
92
|
+
|
|
63
93
|
result = audit_integration(model, integration, entrypoints, handlers, config=config)
|
|
64
94
|
formatters.audit(result, output_format, directory, console)
|
|
65
95
|
|
|
66
96
|
|
|
67
97
|
@app.command(name="entrypoints")
|
|
68
98
|
def list_views(
|
|
99
|
+
filter_arg: Annotated[
|
|
100
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
|
|
101
|
+
] = None,
|
|
69
102
|
directory: Annotated[
|
|
70
103
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
71
104
|
] = Path("."),
|
|
@@ -74,12 +107,15 @@ def list_views(
|
|
|
74
107
|
) -> None:
|
|
75
108
|
"""List Django views (APIView, ViewSet, @api_view).
|
|
76
109
|
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
Examples:
|
|
111
|
+
bubble django entrypoints # All views
|
|
112
|
+
bubble django entrypoints /users # Views matching /users
|
|
113
|
+
bubble django entrypoints views/users.py # Views in specific file
|
|
79
114
|
"""
|
|
80
115
|
directory = directory.resolve()
|
|
81
116
|
model = _build_model(directory, use_cache=not no_cache)
|
|
82
117
|
entrypoints, _ = _get_django_entrypoints_and_handlers(model)
|
|
118
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
83
119
|
result = list_integration_entrypoints(integration, entrypoints)
|
|
84
120
|
formatters.entrypoints(result, output_format, directory, console)
|
|
85
121
|
|
|
@@ -23,6 +23,17 @@ DRF_BASE_CLASSES = {
|
|
|
23
23
|
"RetrieveUpdateDestroyAPIView",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
DRF_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
27
|
+
DRF_ACTION_METHODS = {"list", "create", "retrieve", "update", "partial_update", "destroy"}
|
|
28
|
+
DRF_METHOD_TO_HTTP = {
|
|
29
|
+
"list": "GET",
|
|
30
|
+
"create": "POST",
|
|
31
|
+
"retrieve": "GET",
|
|
32
|
+
"update": "PUT",
|
|
33
|
+
"partial_update": "PATCH",
|
|
34
|
+
"destroy": "DELETE",
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
DRF_GENERICS_QUALIFIERS = {
|
|
27
38
|
"generics",
|
|
28
39
|
"rest_framework.generics",
|
|
@@ -51,31 +62,65 @@ class DjangoViewVisitor(cst.CSTVisitor):
|
|
|
51
62
|
self._in_class: str | None = None
|
|
52
63
|
self._class_is_view = False
|
|
53
64
|
self._class_line: int = 0
|
|
65
|
+
self._class_methods: dict[str, int] = {}
|
|
54
66
|
|
|
55
67
|
def visit_ClassDef(self, node: cst.ClassDef) -> bool:
|
|
56
68
|
self._in_class = node.name.value
|
|
57
69
|
self._class_is_view = self._is_view_class(node)
|
|
70
|
+
self._class_methods = {}
|
|
58
71
|
if self._class_is_view:
|
|
59
72
|
pos = self.get_metadata(PositionProvider, node)
|
|
60
73
|
self._class_line = pos.start.line
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"view_type": "class",
|
|
70
|
-
},
|
|
71
|
-
)
|
|
72
|
-
)
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
77
|
+
if self._class_is_view and self._in_class:
|
|
78
|
+
method_name = node.name.value.lower()
|
|
79
|
+
if method_name in DRF_HTTP_METHODS or method_name in DRF_ACTION_METHODS:
|
|
80
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
81
|
+
self._class_methods[method_name] = pos.start.line
|
|
73
82
|
return True
|
|
74
83
|
|
|
75
84
|
def leave_ClassDef(self, node: cst.ClassDef) -> None:
|
|
85
|
+
if self._class_is_view and self._in_class:
|
|
86
|
+
class_name = self._in_class
|
|
87
|
+
if self._class_methods:
|
|
88
|
+
for method_name, line in self._class_methods.items():
|
|
89
|
+
http_method = DRF_METHOD_TO_HTTP.get(method_name, method_name.upper())
|
|
90
|
+
self.entrypoints.append(
|
|
91
|
+
Entrypoint(
|
|
92
|
+
file=self.file_path,
|
|
93
|
+
function=f"{class_name}.{method_name}",
|
|
94
|
+
line=line,
|
|
95
|
+
kind="http_route",
|
|
96
|
+
metadata={
|
|
97
|
+
"framework": "django",
|
|
98
|
+
"view_type": "class",
|
|
99
|
+
"http_method": http_method,
|
|
100
|
+
"http_path": f"<drf:{class_name}>",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
self.entrypoints.append(
|
|
106
|
+
Entrypoint(
|
|
107
|
+
file=self.file_path,
|
|
108
|
+
function=class_name,
|
|
109
|
+
line=self._class_line,
|
|
110
|
+
kind="http_route",
|
|
111
|
+
metadata={
|
|
112
|
+
"framework": "django",
|
|
113
|
+
"view_type": "class",
|
|
114
|
+
"http_method": "ANY",
|
|
115
|
+
"http_path": f"<drf:{class_name}>",
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
76
120
|
self._in_class = None
|
|
77
121
|
self._class_is_view = False
|
|
78
122
|
self._class_line = 0
|
|
123
|
+
self._class_methods = {}
|
|
79
124
|
|
|
80
125
|
def _is_view_class(self, node: cst.ClassDef) -> bool:
|
|
81
126
|
"""Check if a class inherits from a Django/DRF view base class."""
|
|
@@ -40,8 +40,28 @@ def _get_fastapi_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, li
|
|
|
40
40
|
return entrypoints, handlers
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _filter_entrypoints(
|
|
44
|
+
entrypoints: list, filter_arg: str | None, directory: Path
|
|
45
|
+
) -> list:
|
|
46
|
+
"""Filter entrypoints by file path or route path."""
|
|
47
|
+
if not filter_arg:
|
|
48
|
+
return entrypoints
|
|
49
|
+
|
|
50
|
+
if filter_arg.startswith("/"):
|
|
51
|
+
return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
|
|
52
|
+
|
|
53
|
+
filter_path = Path(filter_arg)
|
|
54
|
+
if not filter_path.is_absolute():
|
|
55
|
+
filter_path = directory / filter_path
|
|
56
|
+
|
|
57
|
+
return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
@app.command()
|
|
44
61
|
def audit(
|
|
62
|
+
filter_arg: Annotated[
|
|
63
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
|
|
64
|
+
] = None,
|
|
45
65
|
directory: Annotated[
|
|
46
66
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
47
67
|
] = Path("."),
|
|
@@ -50,21 +70,34 @@ def audit(
|
|
|
50
70
|
) -> None:
|
|
51
71
|
"""Check FastAPI routes for escaping exceptions.
|
|
52
72
|
|
|
53
|
-
Scans
|
|
73
|
+
Scans FastAPI HTTP routes and reports which have uncaught exceptions.
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
Examples:
|
|
76
|
+
bubble fastapi audit # All routes
|
|
77
|
+
bubble fastapi audit /users # Routes matching /users
|
|
78
|
+
bubble fastapi audit routers/users.py # Routes in specific file
|
|
58
79
|
"""
|
|
59
80
|
directory = directory.resolve()
|
|
60
81
|
model = _build_model(directory, use_cache=not no_cache)
|
|
61
82
|
entrypoints, handlers = _get_fastapi_entrypoints_and_handlers(model)
|
|
83
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
84
|
+
|
|
85
|
+
if filter_arg and not entrypoints:
|
|
86
|
+
if filter_arg.startswith("/"):
|
|
87
|
+
console.print(f"[yellow]No FastAPI routes found matching {filter_arg}[/yellow]")
|
|
88
|
+
else:
|
|
89
|
+
console.print(f"[yellow]No FastAPI routes found in {filter_arg}[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
|
|
62
92
|
result = audit_integration(model, integration, entrypoints, handlers)
|
|
63
93
|
formatters.audit(result, OutputFormat(output_format), directory, console)
|
|
64
94
|
|
|
65
95
|
|
|
66
96
|
@app.command(name="entrypoints")
|
|
67
97
|
def list_routes(
|
|
98
|
+
filter_arg: Annotated[
|
|
99
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
|
|
100
|
+
] = None,
|
|
68
101
|
directory: Annotated[
|
|
69
102
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
70
103
|
] = Path("."),
|
|
@@ -73,12 +106,15 @@ def list_routes(
|
|
|
73
106
|
) -> None:
|
|
74
107
|
"""List FastAPI HTTP routes.
|
|
75
108
|
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
Examples:
|
|
110
|
+
bubble fastapi entrypoints # All routes
|
|
111
|
+
bubble fastapi entrypoints /users # Routes matching /users
|
|
112
|
+
bubble fastapi entrypoints routers/users.py # Routes in specific file
|
|
78
113
|
"""
|
|
79
114
|
directory = directory.resolve()
|
|
80
115
|
model = _build_model(directory, use_cache=not no_cache)
|
|
81
116
|
entrypoints, _ = _get_fastapi_entrypoints_and_handlers(model)
|
|
117
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
82
118
|
result = list_integration_entrypoints(integration, entrypoints)
|
|
83
119
|
formatters.entrypoints(result, OutputFormat(output_format), directory, console)
|
|
84
120
|
|
|
@@ -5,7 +5,9 @@ import typer
|
|
|
5
5
|
from bubble.integrations.base import Entrypoint, GlobalHandler
|
|
6
6
|
from bubble.integrations.flask.detector import (
|
|
7
7
|
FlaskErrorHandlerVisitor,
|
|
8
|
+
FlaskRESTfulVisitor,
|
|
8
9
|
FlaskRouteVisitor,
|
|
10
|
+
correlate_flask_restful_entrypoints,
|
|
9
11
|
detect_flask_entrypoints,
|
|
10
12
|
detect_flask_global_handlers,
|
|
11
13
|
)
|
|
@@ -50,8 +52,10 @@ class FlaskIntegration:
|
|
|
50
52
|
__all__ = [
|
|
51
53
|
"FlaskIntegration",
|
|
52
54
|
"FlaskRouteVisitor",
|
|
55
|
+
"FlaskRESTfulVisitor",
|
|
53
56
|
"FlaskErrorHandlerVisitor",
|
|
54
57
|
"EXCEPTION_RESPONSES",
|
|
58
|
+
"correlate_flask_restful_entrypoints",
|
|
55
59
|
"detect_flask_entrypoints",
|
|
56
60
|
"detect_flask_global_handlers",
|
|
57
61
|
]
|
bubble/integrations/flask/cli.py
CHANGED
|
@@ -40,8 +40,28 @@ def _get_flask_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, list
|
|
|
40
40
|
return entrypoints, handlers
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _filter_entrypoints(
|
|
44
|
+
entrypoints: list, filter_arg: str | None, directory: Path
|
|
45
|
+
) -> list:
|
|
46
|
+
"""Filter entrypoints by file path or route path."""
|
|
47
|
+
if not filter_arg:
|
|
48
|
+
return entrypoints
|
|
49
|
+
|
|
50
|
+
if filter_arg.startswith("/"):
|
|
51
|
+
return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
|
|
52
|
+
|
|
53
|
+
filter_path = Path(filter_arg)
|
|
54
|
+
if not filter_path.is_absolute():
|
|
55
|
+
filter_path = directory / filter_path
|
|
56
|
+
|
|
57
|
+
return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
@app.command()
|
|
44
61
|
def audit(
|
|
62
|
+
filter_arg: Annotated[
|
|
63
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /balance)")
|
|
64
|
+
] = None,
|
|
45
65
|
directory: Annotated[
|
|
46
66
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
47
67
|
] = Path("."),
|
|
@@ -50,21 +70,34 @@ def audit(
|
|
|
50
70
|
) -> None:
|
|
51
71
|
"""Check Flask routes for escaping exceptions.
|
|
52
72
|
|
|
53
|
-
Scans
|
|
73
|
+
Scans Flask HTTP routes and reports which have uncaught exceptions.
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
Examples:
|
|
76
|
+
bubble flask audit # All routes
|
|
77
|
+
bubble flask audit /balance # Routes matching /balance
|
|
78
|
+
bubble flask audit blueprints/api.py # Routes in specific file
|
|
58
79
|
"""
|
|
59
80
|
directory = directory.resolve()
|
|
60
81
|
model = _build_model(directory, use_cache=not no_cache)
|
|
61
82
|
entrypoints, handlers = _get_flask_entrypoints_and_handlers(model)
|
|
83
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
84
|
+
|
|
85
|
+
if filter_arg and not entrypoints:
|
|
86
|
+
if filter_arg.startswith("/"):
|
|
87
|
+
console.print(f"[yellow]No Flask routes found matching {filter_arg}[/yellow]")
|
|
88
|
+
else:
|
|
89
|
+
console.print(f"[yellow]No Flask routes found in {filter_arg}[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
|
|
62
92
|
result = audit_integration(model, integration, entrypoints, handlers)
|
|
63
93
|
formatters.audit(result, OutputFormat(output_format), directory, console)
|
|
64
94
|
|
|
65
95
|
|
|
66
96
|
@app.command(name="entrypoints")
|
|
67
97
|
def list_routes(
|
|
98
|
+
filter_arg: Annotated[
|
|
99
|
+
str | None, typer.Argument(help="Filter by file path or route (e.g., /balance)")
|
|
100
|
+
] = None,
|
|
68
101
|
directory: Annotated[
|
|
69
102
|
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
70
103
|
] = Path("."),
|
|
@@ -73,12 +106,15 @@ def list_routes(
|
|
|
73
106
|
) -> None:
|
|
74
107
|
"""List Flask HTTP routes.
|
|
75
108
|
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
Examples:
|
|
110
|
+
bubble flask entrypoints # All routes
|
|
111
|
+
bubble flask entrypoints /users # Routes matching /users
|
|
112
|
+
bubble flask entrypoints blueprints/api.py # Routes in specific file
|
|
78
113
|
"""
|
|
79
114
|
directory = directory.resolve()
|
|
80
115
|
model = _build_model(directory, use_cache=not no_cache)
|
|
81
116
|
entrypoints, _ = _get_flask_entrypoints_and_handlers(model)
|
|
117
|
+
entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
|
|
82
118
|
result = list_integration_entrypoints(integration, entrypoints)
|
|
83
119
|
formatters.entrypoints(result, OutputFormat(output_format), directory, console)
|
|
84
120
|
|
|
@@ -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(
|
|
172
|
-
|
|
328
|
+
wrapper.visit(route_visitor)
|
|
329
|
+
entrypoints.extend(route_visitor.entrypoints)
|
|
173
330
|
except Exception:
|
|
174
|
-
|
|
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
|
],
|
|
@@ -104,6 +111,17 @@ def audit(
|
|
|
104
111
|
if len(raise_sites) > 2:
|
|
105
112
|
console.print(f" [dim]...and {len(raise_sites) - 2} more[/dim]")
|
|
106
113
|
|
|
114
|
+
for exc_type, raise_sites in issue.caught_by_remote.items():
|
|
115
|
+
exc_simple = exc_type.split(".")[-1]
|
|
116
|
+
for rs in raise_sites[:2]:
|
|
117
|
+
rel = _rel_path(rs.file, directory)
|
|
118
|
+
console.print(
|
|
119
|
+
f" [blue]{exc_simple}[/blue] [dim]({rel}:{rs.line}) "
|
|
120
|
+
f"- caught by handler in different file[/dim]"
|
|
121
|
+
)
|
|
122
|
+
if len(raise_sites) > 2:
|
|
123
|
+
console.print(f" [dim]...and {len(raise_sites) - 2} more[/dim]")
|
|
124
|
+
|
|
107
125
|
console.print()
|
|
108
126
|
else:
|
|
109
127
|
console.print("[green bold]All entrypoints have specific exception handlers[/green bold]\n")
|
bubble/integrations/models.py
CHANGED
|
@@ -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
|
|
bubble/integrations/queries.py
CHANGED
|
@@ -51,6 +51,7 @@ def _compute_exception_flow_for_integration(
|
|
|
51
51
|
forward_graph: dict[str, set[str]] | None = None,
|
|
52
52
|
name_to_qualified: dict[str, list[str]] | None = None,
|
|
53
53
|
config: FlowConfig | None = None,
|
|
54
|
+
entrypoint_file: str | None = None,
|
|
54
55
|
) -> ExceptionFlow:
|
|
55
56
|
"""Compute exception flow for a function with integration-specific handling.
|
|
56
57
|
|
|
@@ -134,9 +135,17 @@ def _compute_exception_flow_for_integration(
|
|
|
134
135
|
flow.caught_by_generic[exc_type] = []
|
|
135
136
|
flow.caught_by_generic[exc_type].extend(raise_sites)
|
|
136
137
|
else:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
is_same_file = (
|
|
139
|
+
entrypoint_file is not None and caught_by_handler.file == entrypoint_file
|
|
140
|
+
)
|
|
141
|
+
if is_same_file:
|
|
142
|
+
if exc_type not in flow.caught_by_global:
|
|
143
|
+
flow.caught_by_global[exc_type] = []
|
|
144
|
+
flow.caught_by_global[exc_type].extend(raise_sites)
|
|
145
|
+
else:
|
|
146
|
+
if exc_type not in flow.caught_by_remote_global:
|
|
147
|
+
flow.caught_by_remote_global[exc_type] = []
|
|
148
|
+
flow.caught_by_remote_global[exc_type].extend(raise_sites)
|
|
140
149
|
continue
|
|
141
150
|
|
|
142
151
|
framework_response = integration.get_exception_response(exc_type)
|
|
@@ -218,19 +227,24 @@ def audit_integration(
|
|
|
218
227
|
forward_graph,
|
|
219
228
|
name_to_qualified,
|
|
220
229
|
config,
|
|
230
|
+
entrypoint_file=entrypoint.file,
|
|
221
231
|
)
|
|
222
232
|
|
|
223
233
|
real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
|
|
224
234
|
real_generic = {
|
|
225
235
|
k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
|
|
226
236
|
}
|
|
237
|
+
real_remote = {
|
|
238
|
+
k: v for k, v in flow.caught_by_remote_global.items() if k not in reraise_patterns
|
|
239
|
+
}
|
|
227
240
|
|
|
228
|
-
if real_uncaught or real_generic:
|
|
241
|
+
if real_uncaught or real_generic or real_remote:
|
|
229
242
|
issues.append(
|
|
230
243
|
AuditIssue(
|
|
231
244
|
entrypoint=entrypoint,
|
|
232
245
|
uncaught=real_uncaught,
|
|
233
246
|
caught_by_generic=real_generic,
|
|
247
|
+
caught_by_remote=real_remote,
|
|
234
248
|
caught=flow.caught_by_global,
|
|
235
249
|
)
|
|
236
250
|
)
|
|
@@ -399,10 +413,14 @@ def _trace_to_entrypoints(
|
|
|
399
413
|
return
|
|
400
414
|
visited.add(current)
|
|
401
415
|
|
|
402
|
-
|
|
403
|
-
current_simple =
|
|
416
|
+
current_qualified = current.split("::")[-1] if "::" in current else current
|
|
417
|
+
current_simple = current_qualified.split(".")[-1]
|
|
404
418
|
|
|
405
|
-
if
|
|
419
|
+
if (
|
|
420
|
+
current in entrypoint_functions
|
|
421
|
+
or current_qualified in entrypoint_functions
|
|
422
|
+
or current_simple in entrypoint_functions
|
|
423
|
+
):
|
|
406
424
|
paths.append(list(path))
|
|
407
425
|
return
|
|
408
426
|
|
|
@@ -411,13 +429,11 @@ def _trace_to_entrypoints(
|
|
|
411
429
|
if len(paths) >= max_paths:
|
|
412
430
|
return
|
|
413
431
|
if reachable_from_entrypoints is not None:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if "::" in caller
|
|
417
|
-
else caller.split(".")[-1]
|
|
418
|
-
)
|
|
432
|
+
caller_qualified = caller.split("::")[-1] if "::" in caller else caller
|
|
433
|
+
caller_simple = caller_qualified.split(".")[-1]
|
|
419
434
|
if (
|
|
420
435
|
caller not in reachable_from_entrypoints
|
|
436
|
+
and caller_qualified not in reachable_from_entrypoints
|
|
421
437
|
and caller_simple not in reachable_from_entrypoints
|
|
422
438
|
):
|
|
423
439
|
continue
|
|
@@ -460,7 +476,9 @@ def trace_routes_to_exception(
|
|
|
460
476
|
endpoint = path[-1]
|
|
461
477
|
entrypoints_reached.add(endpoint)
|
|
462
478
|
if "::" in endpoint:
|
|
463
|
-
|
|
479
|
+
qualified_part = endpoint.split("::")[-1]
|
|
480
|
+
entrypoints_reached.add(qualified_part)
|
|
481
|
+
entrypoints_reached.add(qualified_part.split(".")[-1])
|
|
464
482
|
|
|
465
483
|
matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
|
|
466
484
|
|
bubble/propagation.py
CHANGED
|
@@ -39,10 +39,20 @@ class PropagatedRaise:
|
|
|
39
39
|
|
|
40
40
|
@dataclass
|
|
41
41
|
class ExceptionFlow:
|
|
42
|
-
"""The computed exception flow for a function or entrypoint.
|
|
42
|
+
"""The computed exception flow for a function or entrypoint.
|
|
43
|
+
|
|
44
|
+
Categories:
|
|
45
|
+
- caught_locally: Caught by try/except in the function
|
|
46
|
+
- caught_by_global: Caught by a global handler in the same file as the entrypoint
|
|
47
|
+
- caught_by_remote_global: Caught by a global handler in a different file
|
|
48
|
+
- caught_by_generic: Caught by a generic handler (@errorhandler(Exception))
|
|
49
|
+
- uncaught: No handler found
|
|
50
|
+
- framework_handled: Converted to HTTP response by framework (e.g., HTTPException)
|
|
51
|
+
"""
|
|
43
52
|
|
|
44
53
|
caught_locally: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
45
54
|
caught_by_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
55
|
+
caught_by_remote_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
46
56
|
caught_by_generic: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
47
57
|
uncaught: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
48
58
|
framework_handled: dict[str, list[tuple[RaiseSite, str]]] = field(default_factory=dict)
|
|
@@ -61,6 +71,34 @@ class PropagationResult:
|
|
|
61
71
|
)
|
|
62
72
|
|
|
63
73
|
|
|
74
|
+
def _normalize_callee_to_file_format(
|
|
75
|
+
callee_qualified: str, model: ProgramModel
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Normalize a module-dot callee to file::function format.
|
|
78
|
+
|
|
79
|
+
Converts: blueprints.orchestrators.CallbackOrchestrators.BalanceCallbackOrchestrator.run
|
|
80
|
+
To: blueprints/orchestrators/CallbackOrchestrators.py::BalanceCallbackOrchestrator.run
|
|
81
|
+
"""
|
|
82
|
+
if "::" in callee_qualified:
|
|
83
|
+
return callee_qualified
|
|
84
|
+
|
|
85
|
+
parts = callee_qualified.split(".")
|
|
86
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
87
|
+
module_path = "/".join(parts[:i]) + ".py"
|
|
88
|
+
func_name = ".".join(parts[i:])
|
|
89
|
+
candidate_key = f"{module_path}::{func_name}"
|
|
90
|
+
|
|
91
|
+
if candidate_key in model.functions:
|
|
92
|
+
return candidate_key
|
|
93
|
+
|
|
94
|
+
for key in model.functions:
|
|
95
|
+
if key.endswith(f"::{func_name}") or key.endswith(f":{func_name}"):
|
|
96
|
+
if module_path.replace("/", ".").replace(".py", "") in key.replace("/", "."):
|
|
97
|
+
return f"{key.split(':')[0]}::{func_name}"
|
|
98
|
+
|
|
99
|
+
return callee_qualified
|
|
100
|
+
|
|
101
|
+
|
|
64
102
|
def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
|
|
65
103
|
"""Build a map from caller to callees."""
|
|
66
104
|
graph: dict[str, set[str]] = {}
|
|
@@ -69,6 +107,9 @@ def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
|
|
|
69
107
|
caller = call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
|
|
70
108
|
callee = call_site.callee_qualified or call_site.callee_name
|
|
71
109
|
|
|
110
|
+
if call_site.callee_qualified and "::" not in call_site.callee_qualified:
|
|
111
|
+
callee = _normalize_callee_to_file_format(call_site.callee_qualified, model)
|
|
112
|
+
|
|
72
113
|
if caller not in graph:
|
|
73
114
|
graph[caller] = set()
|
|
74
115
|
graph[caller].add(callee)
|
|
@@ -218,6 +259,23 @@ def _build_call_site_lookup(
|
|
|
218
259
|
return lookup
|
|
219
260
|
|
|
220
261
|
|
|
262
|
+
def _build_is_method_lookup(model: ProgramModel) -> dict[tuple[str, str], bool]:
|
|
263
|
+
"""Build a lookup for is_method_call by (caller, callee) pairs.
|
|
264
|
+
|
|
265
|
+
This is always needed for correct fallback resolution, even when
|
|
266
|
+
skip_evidence=True. When a call is in the form obj.method(), we
|
|
267
|
+
should prefer matching methods over standalone functions.
|
|
268
|
+
"""
|
|
269
|
+
lookup: dict[tuple[str, str], bool] = {}
|
|
270
|
+
for cs in model.call_sites:
|
|
271
|
+
caller = cs.caller_qualified or f"{cs.file}::{cs.caller_function}"
|
|
272
|
+
callee = cs.callee_qualified or cs.callee_name
|
|
273
|
+
key = (caller, callee)
|
|
274
|
+
if key not in lookup:
|
|
275
|
+
lookup[key] = cs.is_method_call
|
|
276
|
+
return lookup
|
|
277
|
+
|
|
278
|
+
|
|
221
279
|
def _build_raise_site_lookup(
|
|
222
280
|
model: ProgramModel,
|
|
223
281
|
) -> dict[tuple[str, str, int], RaiseSite]:
|
|
@@ -278,9 +336,7 @@ def _scoped_fallback_lookup(
|
|
|
278
336
|
candidates = name_to_qualified.get(fallback_key, [])
|
|
279
337
|
|
|
280
338
|
if not candidates:
|
|
281
|
-
|
|
282
|
-
_fallback_cache[cache_key] = result
|
|
283
|
-
return result
|
|
339
|
+
return ([], "none")
|
|
284
340
|
|
|
285
341
|
same_file = [c for c in candidates if c.startswith(f"{caller_file}::")]
|
|
286
342
|
if same_file:
|
|
@@ -347,6 +403,7 @@ def propagate_exceptions(
|
|
|
347
403
|
catches_by_function = compute_catches_by_function(model)
|
|
348
404
|
forward_graph = build_forward_call_graph(model)
|
|
349
405
|
call_site_lookup = _build_call_site_lookup(model) if not skip_evidence else {}
|
|
406
|
+
is_method_lookup = _build_is_method_lookup(model)
|
|
350
407
|
|
|
351
408
|
propagated: dict[str, set[str]] = {}
|
|
352
409
|
propagated_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = {}
|
|
@@ -418,7 +475,7 @@ def propagate_exceptions(
|
|
|
418
475
|
if "::" in expanded_callee
|
|
419
476
|
else expanded_callee.split(".")[-1]
|
|
420
477
|
)
|
|
421
|
-
is_method =
|
|
478
|
+
is_method = is_method_lookup.get((caller, callee), False)
|
|
422
479
|
caller_file = caller.split("::")[0] if "::" in caller else caller
|
|
423
480
|
import_map = model.import_maps.get(caller_file, {})
|
|
424
481
|
|
bubble/queries.py
CHANGED
|
@@ -7,7 +7,7 @@ and returns a typed result dataclass. No formatting here.
|
|
|
7
7
|
from difflib import get_close_matches
|
|
8
8
|
|
|
9
9
|
from bubble.enums import EntrypointKind, Framework, ResolutionMode
|
|
10
|
-
from bubble.models import Entrypoint, ProgramModel
|
|
10
|
+
from bubble.models import CatchSite, ClassHierarchy, Entrypoint, ProgramModel, RaiseSite
|
|
11
11
|
from bubble.propagation import (
|
|
12
12
|
build_forward_call_graph,
|
|
13
13
|
build_reverse_call_graph,
|
|
@@ -320,28 +320,110 @@ def find_escapes(
|
|
|
320
320
|
)
|
|
321
321
|
|
|
322
322
|
|
|
323
|
+
def _compute_reverse_reachability(
|
|
324
|
+
raise_sites: list[RaiseSite],
|
|
325
|
+
qualified_graph: dict[str, set[str]],
|
|
326
|
+
name_graph: dict[str, set[str]],
|
|
327
|
+
) -> set[str]:
|
|
328
|
+
"""Compute all functions that can transitively call the raise site functions."""
|
|
329
|
+
reachable: set[str] = set()
|
|
330
|
+
|
|
331
|
+
for raise_site in raise_sites:
|
|
332
|
+
func_key = f"{raise_site.file}::{raise_site.function}"
|
|
333
|
+
worklist = [func_key]
|
|
334
|
+
visited: set[str] = set()
|
|
335
|
+
|
|
336
|
+
while worklist:
|
|
337
|
+
current = worklist.pop()
|
|
338
|
+
if current in visited:
|
|
339
|
+
continue
|
|
340
|
+
visited.add(current)
|
|
341
|
+
reachable.add(current)
|
|
342
|
+
|
|
343
|
+
simple_name = (
|
|
344
|
+
current.split("::")[-1].split(".")[-1]
|
|
345
|
+
if "::" in current
|
|
346
|
+
else current.split(".")[-1]
|
|
347
|
+
)
|
|
348
|
+
reachable.add(simple_name)
|
|
349
|
+
|
|
350
|
+
for caller in qualified_graph.get(current, set()):
|
|
351
|
+
if caller not in visited:
|
|
352
|
+
worklist.append(caller)
|
|
353
|
+
|
|
354
|
+
for caller in name_graph.get(simple_name, set()):
|
|
355
|
+
if caller not in visited:
|
|
356
|
+
worklist.append(caller)
|
|
357
|
+
|
|
358
|
+
return reachable
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _catch_site_catches_exception(
|
|
362
|
+
catch_site: CatchSite,
|
|
363
|
+
types_to_find: set[str],
|
|
364
|
+
hierarchy: ClassHierarchy,
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""Check if a catch site would catch the given exception type."""
|
|
367
|
+
if catch_site.has_bare_except:
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
for caught_type in catch_site.caught_types:
|
|
371
|
+
caught_simple = caught_type.split(".")[-1]
|
|
372
|
+
|
|
373
|
+
if caught_type in types_to_find or caught_simple in types_to_find:
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
if caught_simple in ("Exception", "BaseException"):
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
for t in types_to_find:
|
|
380
|
+
t_simple = t.split(".")[-1]
|
|
381
|
+
if hierarchy.is_subclass_of(t_simple, caught_simple):
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
323
387
|
def find_catches(
|
|
324
388
|
model: ProgramModel,
|
|
325
389
|
exception_type: str,
|
|
326
390
|
include_subclasses: bool = False,
|
|
327
391
|
) -> CatchesResult:
|
|
328
|
-
"""Find
|
|
392
|
+
"""Find catch sites that are in the call path from where the exception is raised."""
|
|
329
393
|
types_to_find: set[str] = {exception_type}
|
|
330
394
|
if include_subclasses:
|
|
331
395
|
subclasses = model.exception_hierarchy.get_subclasses(exception_type)
|
|
332
396
|
types_to_find.update(subclasses)
|
|
333
397
|
|
|
334
|
-
|
|
398
|
+
raises_result = find_raises(model, exception_type, include_subclasses)
|
|
399
|
+
|
|
400
|
+
if not raises_result.matches:
|
|
401
|
+
return CatchesResult(
|
|
402
|
+
exception_type=exception_type,
|
|
403
|
+
include_subclasses=include_subclasses,
|
|
404
|
+
types_searched=types_to_find,
|
|
405
|
+
local_catches=[],
|
|
406
|
+
global_handlers=[],
|
|
407
|
+
raise_site_count=0,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
qualified_graph, name_graph = build_reverse_call_graph(model)
|
|
411
|
+
reachable_functions = _compute_reverse_reachability(
|
|
412
|
+
raises_result.matches, qualified_graph, name_graph
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
matching_catches: list[CatchSite] = []
|
|
335
416
|
for catch_site in model.catch_sites:
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
417
|
+
catch_func_key = f"{catch_site.file}::{catch_site.function}"
|
|
418
|
+
catch_func_simple = catch_site.function.split(".")[-1]
|
|
419
|
+
|
|
420
|
+
if catch_func_key not in reachable_functions and catch_func_simple not in reachable_functions:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
if _catch_site_catches_exception(
|
|
424
|
+
catch_site, types_to_find, model.exception_hierarchy
|
|
425
|
+
):
|
|
426
|
+
matching_catches.append(catch_site)
|
|
345
427
|
|
|
346
428
|
global_handlers = [
|
|
347
429
|
h
|
|
@@ -360,6 +442,7 @@ def find_catches(
|
|
|
360
442
|
types_searched=types_to_find,
|
|
361
443
|
local_catches=matching_catches,
|
|
362
444
|
global_handlers=global_handlers,
|
|
445
|
+
raise_site_count=len(raises_result.matches),
|
|
363
446
|
)
|
|
364
447
|
|
|
365
448
|
|
bubble/results.py
CHANGED
|
@@ -2,45 +2,45 @@ bubble/__init__.py,sha256=1xAFxCucbe8Id65q6ReTBn1ipRs5Yn8sRjkREJEu1U8,83
|
|
|
2
2
|
bubble/cache.py,sha256=-cqvtjmc8w_7ozJSXtqxxI1eKaYryuHeb5Vd9mk1kko,7418
|
|
3
3
|
bubble/cli.py,sha256=76Yj7mvczncJEX7ajtiOUjl8-R10n6bUWQBoSigxhnc,17293
|
|
4
4
|
bubble/config.py,sha256=SUwXeHKRkuHOl06JDcoTAe1o5wNIW7kIcBSCxIns3j4,1668
|
|
5
|
-
bubble/detectors.py,sha256=
|
|
5
|
+
bubble/detectors.py,sha256=5MgziIsOGOoUHPQZznN7pb72sAkKd4yXVE5LR8kfeKI,3245
|
|
6
6
|
bubble/enums.py,sha256=GuPbw2nr076fcnBQpOlKLrEc7rrf2Vnk8XjT2O37CfQ,1516
|
|
7
|
-
bubble/extractor.py,sha256=
|
|
8
|
-
bubble/formatters.py,sha256=
|
|
7
|
+
bubble/extractor.py,sha256=IdwJMXdVBBowYfGgNES-RFI7y_mzTrqzWnKT4fqovpg,30615
|
|
8
|
+
bubble/formatters.py,sha256=tjcG2EfTNws953vzFc-uDf1XDh1XdgK91rySTkTctZw,34306
|
|
9
9
|
bubble/loader.py,sha256=-KepjgS9EVk0JjCyGrX2xr6ihbbWyGDrVE61IhlFEJE,4217
|
|
10
10
|
bubble/models.py,sha256=_v4k3Wf2ZBQC6pZkBLU_cbcm0PldrwQ-CsryD5TbiAE,12315
|
|
11
|
-
bubble/propagation.py,sha256=
|
|
11
|
+
bubble/propagation.py,sha256=V9iw1DiNbIhixAgcs1RLHOB-C-quDa4wrlCIJ5nQ3Xs,30293
|
|
12
12
|
bubble/protocols.py,sha256=ER05yyvNDAcOpbgo3SFDHcccn2tFbp7SFGGVq9-kG48,3443
|
|
13
|
-
bubble/queries.py,sha256=
|
|
14
|
-
bubble/results.py,sha256=
|
|
13
|
+
bubble/queries.py,sha256=WvxBRep_E_lUGTfpSmDQK6_NZs3QP-MduH5P6OwxW_Q,22961
|
|
14
|
+
bubble/results.py,sha256=xTI4a5re_e9OCvu9jkvf811P3weI-fsOPeDfRzxocnQ,4160
|
|
15
15
|
bubble/stubs.py,sha256=jfM_Ji4ZrTM4UWrfB-lZtzHXbqbx4uFDF_kQOZtPvUA,2786
|
|
16
16
|
bubble/timing.py,sha256=COoo50IfYhyvC5xXWS2L8vj9ZOoSk_SyC9v0Kt_rt6M,3828
|
|
17
17
|
bubble/integrations/__init__.py,sha256=v5nUEGJJgJzpSljakQdAR2qKpheAugprBqrYb1GF7xc,2602
|
|
18
18
|
bubble/integrations/base.py,sha256=veDox95mLw5MOTb0_9XogahEf-1GKyV1K0G7MnV-R-I,2853
|
|
19
|
-
bubble/integrations/formatters.py,sha256
|
|
20
|
-
bubble/integrations/models.py,sha256=
|
|
21
|
-
bubble/integrations/queries.py,sha256=
|
|
19
|
+
bubble/integrations/formatters.py,sha256=-zd-hO5W7haWfQejkbXXsjyuSQ2JrEiJN4zJoyyGVVk,10950
|
|
20
|
+
bubble/integrations/models.py,sha256=Ufg8YnzMUIOXnvAwDIvzYali9GD2pL0Gujl-TIftLlk,1954
|
|
21
|
+
bubble/integrations/queries.py,sha256=gs8sgGRWINjowV1aI6ZVe2zJZI3Tk_5YUQzwG7UFzUk,17736
|
|
22
22
|
bubble/integrations/cli_scripts/__init__.py,sha256=4YEoXXapVwz6tbnhTAcpLkC8C9lST7TsQ0XvKbeBG2E,1320
|
|
23
23
|
bubble/integrations/cli_scripts/cli.py,sha256=Kjw02axBGNPpsesgSb9V3DdtG8s-iCcYTJZnaT6KBPA,3930
|
|
24
24
|
bubble/integrations/cli_scripts/detector.py,sha256=mvev_ApKRv0i5WZ3NMbp9HWHA5sndLMpLhgsd5svc7A,4499
|
|
25
25
|
bubble/integrations/django/__init__.py,sha256=KsIzlw8gcMLDEfM54azxciWJaLJrzUyHQbYxs6HuwQU,2037
|
|
26
|
-
bubble/integrations/django/cli.py,sha256=
|
|
27
|
-
bubble/integrations/django/detector.py,sha256=
|
|
26
|
+
bubble/integrations/django/cli.py,sha256=y_di9LBTVlEWlJ58Lp62hNFo6yXiIrIenNE6mTQOnwA,5714
|
|
27
|
+
bubble/integrations/django/detector.py,sha256=wZ6MNUBQ7c2SNRDJt-iAEsAYvqQW-KTXyAogsyM2r58,13327
|
|
28
28
|
bubble/integrations/django/semantics.py,sha256=qdO6UC2U7brzc8SRrARLU9ivOGEQ59pN_TMXFpugYDk,1761
|
|
29
29
|
bubble/integrations/fastapi/__init__.py,sha256=aIV3eWhX9Z_8od6e_EaTU9LCS2BvezgWlQasWTXTjKU,1867
|
|
30
|
-
bubble/integrations/fastapi/cli.py,sha256=
|
|
30
|
+
bubble/integrations/fastapi/cli.py,sha256=HB5_3mDcJVkWOK40gHJBS0WV9gBPk1Hl-pCCBHrlqBQ,5710
|
|
31
31
|
bubble/integrations/fastapi/detector.py,sha256=6LoCdomsHA33wPRpXz6tWCzSFKTmul9NOoChAFSjaWo,5767
|
|
32
32
|
bubble/integrations/fastapi/semantics.py,sha256=xtHs8HUFMzpRMvE1a4uqCflWiGY1-5_v3ACCz0BBnUg,499
|
|
33
|
-
bubble/integrations/flask/__init__.py,sha256=
|
|
34
|
-
bubble/integrations/flask/cli.py,sha256=
|
|
35
|
-
bubble/integrations/flask/detector.py,sha256=
|
|
33
|
+
bubble/integrations/flask/__init__.py,sha256=1fmMcxLOzIjU3aToF1vGnxeo-UwAVaKlPopnV2KPgnw,1959
|
|
34
|
+
bubble/integrations/flask/cli.py,sha256=Xkmg_QgLz6kMU7MS1staARBl4tCr0efvd7fzoZqgJtw,5672
|
|
35
|
+
bubble/integrations/flask/detector.py,sha256=gVg6pUUC3e3NPps2ZlLZfpGDO9Sn32AtJy171oSKSHE,15447
|
|
36
36
|
bubble/integrations/flask/semantics.py,sha256=gf95XfpaztI1ASQfen38wyjbbLVIH_H2Xc6YwScCBV4,675
|
|
37
37
|
bubble/integrations/generic/__init__.py,sha256=daDOwBkgmyA6mHOi6JbznZfSkIJx_6TMoy30wrKOPHE,321
|
|
38
38
|
bubble/integrations/generic/config.py,sha256=61mKh9xRKPPo0sfI_SFQnIvoEMaUNFrbE21215LM1gc,3236
|
|
39
39
|
bubble/integrations/generic/detector.py,sha256=Z0PSRLVF96CJ-FN473M8Txj28etMeEJS0fmaPV1Ev6M,12460
|
|
40
40
|
bubble/integrations/generic/frameworks.py,sha256=s8fpdPD_71ZJvIbPv4VVvplLnp7cAb5W418fg5hu0uM,4060
|
|
41
|
-
bubble_analysis-0.
|
|
42
|
-
bubble_analysis-0.
|
|
43
|
-
bubble_analysis-0.
|
|
44
|
-
bubble_analysis-0.
|
|
45
|
-
bubble_analysis-0.
|
|
46
|
-
bubble_analysis-0.
|
|
41
|
+
bubble_analysis-0.3.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
42
|
+
bubble_analysis-0.3.0.dist-info/METADATA,sha256=C7qAa7iPLYcxyvrGXXT-2stTVm0nzM6pL9FeXmQWQlk,9040
|
|
43
|
+
bubble_analysis-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
44
|
+
bubble_analysis-0.3.0.dist-info/entry_points.txt,sha256=IsRFTKJQI7FCzniBdLSC4lebLcfyAvPnI9x1gpYFL3c,42
|
|
45
|
+
bubble_analysis-0.3.0.dist-info/top_level.txt,sha256=PI9j20Ka7CoFSwoBiRcLAg9xm-BhUQujkDOn8ykeIjU,7
|
|
46
|
+
bubble_analysis-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|