bubble-analysis 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bubble-analysis might be problematic. Click here for more details.
- {bubble_analysis-0.2.0/bubble_analysis.egg-info → bubble_analysis-0.3.0}/PKG-INFO +1 -1
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/detectors.py +7 -4
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/extractor.py +37 -10
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/formatters.py +7 -1
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/django/cli.py +42 -6
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/django/detector.py +57 -12
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/fastapi/cli.py +42 -6
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/flask/__init__.py +4 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/flask/cli.py +42 -6
- bubble_analysis-0.3.0/bubble/integrations/flask/detector.py +429 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/formatters.py +18 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/models.py +9 -1
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/queries.py +31 -13
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/propagation.py +62 -5
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/queries.py +95 -12
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/results.py +1 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0/bubble_analysis.egg-info}/PKG-INFO +1 -1
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble_analysis.egg-info/SOURCES.txt +2 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/pyproject.toml +2 -1
- bubble_analysis-0.3.0/tests/test_catches.py +108 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_cli_smoke.py +30 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_drf_dispatch.py +3 -3
- bubble_analysis-0.3.0/tests/test_flask_restful.py +89 -0
- bubble_analysis-0.3.0/tests/test_remote_handler.py +69 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_resolution.py +25 -0
- bubble_analysis-0.2.0/bubble/integrations/flask/detector.py +0 -191
- bubble_analysis-0.2.0/tests/test_catches.py +0 -28
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/LICENSE +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/README.md +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/cache.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/cli.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/config.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/enums.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/base.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/cli_scripts/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/cli_scripts/cli.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/cli_scripts/detector.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/django/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/django/semantics.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/fastapi/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/fastapi/detector.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/fastapi/semantics.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/flask/semantics.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/generic/__init__.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/generic/config.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/generic/detector.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/integrations/generic/frameworks.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/loader.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/models.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/protocols.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/stubs.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble/timing.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble_analysis.egg-info/dependency_links.txt +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble_analysis.egg-info/entry_points.txt +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble_analysis.egg-info/requires.txt +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/bubble_analysis.egg-info/top_level.txt +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/setup.cfg +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_callers.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_entrypoints.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_escapes.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_exceptions.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_flask_appbuilder.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_generic_detector.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_generic_handler.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_hierarchy.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_init.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_propagation.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_raises.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_routes_to.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_stats.py +0 -0
- {bubble_analysis-0.2.0 → bubble_analysis-0.3.0}/tests/test_trust_features.py +0 -0
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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
|
]
|
|
@@ -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
|
|