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,176 @@
|
|
|
1
|
+
"""FastAPI route and exception handler detection."""
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from libcst.metadata import MetadataWrapper, PositionProvider
|
|
5
|
+
|
|
6
|
+
from bubble.enums import EntrypointKind, Framework
|
|
7
|
+
from bubble.integrations.base import Entrypoint, GlobalHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FastAPIRouteVisitor(cst.CSTVisitor):
|
|
11
|
+
"""Detects FastAPI route decorators (@router.get, @router.post, etc.)."""
|
|
12
|
+
|
|
13
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
14
|
+
|
|
15
|
+
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head"}
|
|
16
|
+
|
|
17
|
+
def __init__(self, file_path: str) -> None:
|
|
18
|
+
self.file_path = file_path
|
|
19
|
+
self.entrypoints: list[Entrypoint] = []
|
|
20
|
+
|
|
21
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
22
|
+
for decorator in node.decorators:
|
|
23
|
+
route_info = self._parse_route_decorator(decorator)
|
|
24
|
+
if route_info:
|
|
25
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
26
|
+
self.entrypoints.append(
|
|
27
|
+
Entrypoint(
|
|
28
|
+
file=self.file_path,
|
|
29
|
+
function=node.name.value,
|
|
30
|
+
line=pos.start.line,
|
|
31
|
+
kind=EntrypointKind.HTTP_ROUTE,
|
|
32
|
+
metadata={
|
|
33
|
+
"http_method": route_info["method"],
|
|
34
|
+
"http_path": route_info["path"],
|
|
35
|
+
"framework": Framework.FASTAPI,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def _parse_route_decorator(self, decorator: cst.Decorator) -> dict[str, str] | None:
|
|
42
|
+
if not isinstance(decorator.decorator, cst.Call):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
call = decorator.decorator
|
|
46
|
+
|
|
47
|
+
if not isinstance(call.func, cst.Attribute):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
method_name = call.func.attr.value.lower()
|
|
51
|
+
if method_name not in self.HTTP_METHODS:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
path = None
|
|
55
|
+
if call.args:
|
|
56
|
+
first_arg = call.args[0]
|
|
57
|
+
if isinstance(first_arg.value, cst.SimpleString):
|
|
58
|
+
path = first_arg.value.evaluated_value
|
|
59
|
+
|
|
60
|
+
if path:
|
|
61
|
+
return {"path": path, "method": method_name.upper()}
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FastAPIExceptionHandlerVisitor(cst.CSTVisitor):
|
|
66
|
+
"""Detects FastAPI exception handlers.
|
|
67
|
+
|
|
68
|
+
Detects both patterns:
|
|
69
|
+
- app.add_exception_handler(ExceptionType, handler_func)
|
|
70
|
+
- @app.exception_handler(ExceptionType) decorator
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
74
|
+
|
|
75
|
+
def __init__(self, file_path: str) -> None:
|
|
76
|
+
self.file_path = file_path
|
|
77
|
+
self.handlers: list[GlobalHandler] = []
|
|
78
|
+
|
|
79
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
80
|
+
for decorator in node.decorators:
|
|
81
|
+
handler_info = self._parse_exception_handler_decorator(decorator)
|
|
82
|
+
if handler_info:
|
|
83
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
84
|
+
self.handlers.append(
|
|
85
|
+
GlobalHandler(
|
|
86
|
+
file=self.file_path,
|
|
87
|
+
line=pos.start.line,
|
|
88
|
+
function=node.name.value,
|
|
89
|
+
handled_type=handler_info,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def _parse_exception_handler_decorator(self, decorator: cst.Decorator) -> str | None:
|
|
95
|
+
if not isinstance(decorator.decorator, cst.Call):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
call = decorator.decorator
|
|
99
|
+
if not isinstance(call.func, cst.Attribute):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
if call.func.attr.value != "exception_handler":
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if not call.args:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
return self._get_name_from_expr(call.args[0].value)
|
|
109
|
+
|
|
110
|
+
def visit_Call(self, node: cst.Call) -> bool:
|
|
111
|
+
if not isinstance(node.func, cst.Attribute):
|
|
112
|
+
return True
|
|
113
|
+
if node.func.attr.value != "add_exception_handler":
|
|
114
|
+
return True
|
|
115
|
+
if len(node.args) < 2:
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
exception_type = self._get_name_from_expr(node.args[0].value)
|
|
119
|
+
handler_name = self._get_name_from_expr(node.args[1].value)
|
|
120
|
+
|
|
121
|
+
if exception_type and handler_name:
|
|
122
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
123
|
+
self.handlers.append(
|
|
124
|
+
GlobalHandler(
|
|
125
|
+
file=self.file_path,
|
|
126
|
+
line=pos.start.line,
|
|
127
|
+
function=handler_name,
|
|
128
|
+
handled_type=exception_type,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def _get_name_from_expr(self, expr: cst.BaseExpression) -> str:
|
|
135
|
+
if isinstance(expr, cst.Name):
|
|
136
|
+
return expr.value
|
|
137
|
+
elif isinstance(expr, cst.Attribute):
|
|
138
|
+
base = self._get_name_from_expr(expr.value)
|
|
139
|
+
if base:
|
|
140
|
+
return f"{base}.{expr.attr.value}"
|
|
141
|
+
return expr.attr.value
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def detect_fastapi_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
|
|
146
|
+
"""Detect FastAPI route entrypoints in a Python source file."""
|
|
147
|
+
try:
|
|
148
|
+
module = cst.parse_module(source)
|
|
149
|
+
except Exception:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
wrapper = MetadataWrapper(module)
|
|
153
|
+
visitor = FastAPIRouteVisitor(file_path)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
wrapper.visit(visitor)
|
|
157
|
+
return visitor.entrypoints
|
|
158
|
+
except Exception:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def detect_fastapi_global_handlers(source: str, file_path: str) -> list[GlobalHandler]:
|
|
163
|
+
"""Detect FastAPI exception handlers in a Python source file."""
|
|
164
|
+
try:
|
|
165
|
+
module = cst.parse_module(source)
|
|
166
|
+
except Exception:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
wrapper = MetadataWrapper(module)
|
|
170
|
+
visitor = FastAPIExceptionHandlerVisitor(file_path)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
wrapper.visit(visitor)
|
|
174
|
+
return visitor.handlers
|
|
175
|
+
except Exception:
|
|
176
|
+
return []
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FastAPI exception-to-HTTP-response mappings.
|
|
2
|
+
|
|
3
|
+
Defines which exceptions FastAPI converts to HTTP responses automatically.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
EXCEPTION_RESPONSES: dict[str, str] = {
|
|
7
|
+
"fastapi.HTTPException": "HTTP {status_code}",
|
|
8
|
+
"HTTPException": "HTTP {status_code}",
|
|
9
|
+
"starlette.exceptions.HTTPException": "HTTP {status_code}",
|
|
10
|
+
"pydantic.ValidationError": "HTTP 422",
|
|
11
|
+
"pydantic_core.ValidationError": "HTTP 422",
|
|
12
|
+
"ValidationError": "HTTP 422",
|
|
13
|
+
"RequestValidationError": "HTTP 422",
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Flask framework integration for flow analysis."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from bubble.integrations.base import Entrypoint, GlobalHandler
|
|
6
|
+
from bubble.integrations.flask.detector import (
|
|
7
|
+
FlaskErrorHandlerVisitor,
|
|
8
|
+
FlaskRouteVisitor,
|
|
9
|
+
detect_flask_entrypoints,
|
|
10
|
+
detect_flask_global_handlers,
|
|
11
|
+
)
|
|
12
|
+
from bubble.integrations.flask.semantics import EXCEPTION_RESPONSES
|
|
13
|
+
from bubble.integrations.models import IntegrationData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FlaskIntegration:
|
|
17
|
+
"""Flask framework integration."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return "flask"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def cli_app(self) -> typer.Typer:
|
|
25
|
+
from bubble.integrations.flask.cli import app
|
|
26
|
+
|
|
27
|
+
return app
|
|
28
|
+
|
|
29
|
+
def detect_entrypoints(self, source: str, file_path: str) -> list[Entrypoint]:
|
|
30
|
+
return detect_flask_entrypoints(source, file_path)
|
|
31
|
+
|
|
32
|
+
def detect_global_handlers(self, source: str, file_path: str) -> list[GlobalHandler]:
|
|
33
|
+
return detect_flask_global_handlers(source, file_path)
|
|
34
|
+
|
|
35
|
+
def get_exception_response(self, exception_type: str) -> str | None:
|
|
36
|
+
exc_simple = exception_type.split(".")[-1]
|
|
37
|
+
for handled_type, response in EXCEPTION_RESPONSES.items():
|
|
38
|
+
handled_simple = handled_type.split(".")[-1]
|
|
39
|
+
if exc_simple == handled_simple or exception_type == handled_type:
|
|
40
|
+
return response
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def extract_integration_data(self, source: str, file_path: str) -> IntegrationData:
|
|
44
|
+
return IntegrationData(
|
|
45
|
+
entrypoints=self.detect_entrypoints(source, file_path),
|
|
46
|
+
global_handlers=self.detect_global_handlers(source, file_path),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"FlaskIntegration",
|
|
52
|
+
"FlaskRouteVisitor",
|
|
53
|
+
"FlaskErrorHandlerVisitor",
|
|
54
|
+
"EXCEPTION_RESPONSES",
|
|
55
|
+
"detect_flask_entrypoints",
|
|
56
|
+
"detect_flask_global_handlers",
|
|
57
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""CLI commands for Flask integration (flow flask ...)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from bubble.enums import Framework, OutputFormat
|
|
10
|
+
from bubble.extractor import extract_from_directory
|
|
11
|
+
from bubble.integrations import formatters
|
|
12
|
+
from bubble.integrations.flask import FlaskIntegration
|
|
13
|
+
from bubble.integrations.queries import (
|
|
14
|
+
audit_integration,
|
|
15
|
+
list_integration_entrypoints,
|
|
16
|
+
trace_routes_to_exception,
|
|
17
|
+
)
|
|
18
|
+
from bubble.models import ProgramModel
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="flask",
|
|
22
|
+
help="Flask framework-specific commands.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
integration = FlaskIntegration()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_model(directory: Path, use_cache: bool = True) -> ProgramModel:
|
|
31
|
+
"""Build the program model from a directory."""
|
|
32
|
+
with console.status(f"[bold blue]Analyzing[/bold blue] {directory.name}/..."):
|
|
33
|
+
return extract_from_directory(directory, use_cache=use_cache)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_flask_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, list]:
|
|
37
|
+
"""Get Flask entrypoints and global handlers from the model."""
|
|
38
|
+
entrypoints = [e for e in model.entrypoints if e.metadata.get("framework") == Framework.FLASK]
|
|
39
|
+
handlers = model.global_handlers
|
|
40
|
+
return entrypoints, handlers
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def audit(
|
|
45
|
+
directory: Annotated[
|
|
46
|
+
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
47
|
+
] = Path("."),
|
|
48
|
+
output_format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "text",
|
|
49
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Disable caching")] = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Check Flask routes for escaping exceptions.
|
|
52
|
+
|
|
53
|
+
Scans every Flask HTTP route, reports which have uncaught exceptions.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
flow flask audit
|
|
57
|
+
flow flask audit -d /path/to/project
|
|
58
|
+
"""
|
|
59
|
+
directory = directory.resolve()
|
|
60
|
+
model = _build_model(directory, use_cache=not no_cache)
|
|
61
|
+
entrypoints, handlers = _get_flask_entrypoints_and_handlers(model)
|
|
62
|
+
result = audit_integration(model, integration, entrypoints, handlers)
|
|
63
|
+
formatters.audit(result, OutputFormat(output_format), directory, console)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command(name="entrypoints")
|
|
67
|
+
def list_routes(
|
|
68
|
+
directory: Annotated[
|
|
69
|
+
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
70
|
+
] = Path("."),
|
|
71
|
+
output_format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "text",
|
|
72
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Disable caching")] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""List Flask HTTP routes.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
flow flask entrypoints
|
|
78
|
+
"""
|
|
79
|
+
directory = directory.resolve()
|
|
80
|
+
model = _build_model(directory, use_cache=not no_cache)
|
|
81
|
+
entrypoints, _ = _get_flask_entrypoints_and_handlers(model)
|
|
82
|
+
result = list_integration_entrypoints(integration, entrypoints)
|
|
83
|
+
formatters.entrypoints(result, OutputFormat(output_format), directory, console)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command(name="routes-to")
|
|
87
|
+
def routes_to(
|
|
88
|
+
exception_type: Annotated[str, typer.Argument(help="Exception type to trace")],
|
|
89
|
+
directory: Annotated[
|
|
90
|
+
Path, typer.Option("--directory", "-d", help="Directory to analyze")
|
|
91
|
+
] = Path("."),
|
|
92
|
+
include_subclasses: Annotated[
|
|
93
|
+
bool, typer.Option("--include-subclasses", "-s", help="Include subclasses")
|
|
94
|
+
] = False,
|
|
95
|
+
output_format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "text",
|
|
96
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Disable caching")] = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Trace which Flask routes can trigger a given exception.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
flow flask routes-to ValueError
|
|
102
|
+
flow flask routes-to DatabaseError -s
|
|
103
|
+
"""
|
|
104
|
+
directory = directory.resolve()
|
|
105
|
+
model = _build_model(directory, use_cache=not no_cache)
|
|
106
|
+
entrypoints, _ = _get_flask_entrypoints_and_handlers(model)
|
|
107
|
+
result = trace_routes_to_exception(
|
|
108
|
+
model, integration, entrypoints, exception_type, include_subclasses
|
|
109
|
+
)
|
|
110
|
+
formatters.routes_to(result, OutputFormat(output_format), directory, console)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Flask route and error handler detection."""
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from libcst.metadata import MetadataWrapper, PositionProvider
|
|
5
|
+
|
|
6
|
+
from bubble.enums import EntrypointKind, Framework
|
|
7
|
+
from bubble.integrations.base import Entrypoint, GlobalHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlaskRouteVisitor(cst.CSTVisitor):
|
|
11
|
+
"""
|
|
12
|
+
Detects Flask route decorators.
|
|
13
|
+
|
|
14
|
+
Supports:
|
|
15
|
+
- @app.route, @blueprint.route (standard Flask)
|
|
16
|
+
- @expose (Flask-AppBuilder)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
20
|
+
|
|
21
|
+
ROUTE_DECORATOR_NAMES = {"route", "expose"}
|
|
22
|
+
|
|
23
|
+
def __init__(self, file_path: str) -> None:
|
|
24
|
+
self.file_path = file_path
|
|
25
|
+
self.entrypoints: list[Entrypoint] = []
|
|
26
|
+
|
|
27
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
28
|
+
for decorator in node.decorators:
|
|
29
|
+
route_info = self._parse_route_decorator(decorator)
|
|
30
|
+
if route_info:
|
|
31
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
32
|
+
self.entrypoints.append(
|
|
33
|
+
Entrypoint(
|
|
34
|
+
file=self.file_path,
|
|
35
|
+
function=node.name.value,
|
|
36
|
+
line=pos.start.line,
|
|
37
|
+
kind=EntrypointKind.HTTP_ROUTE,
|
|
38
|
+
metadata={
|
|
39
|
+
"http_method": route_info["method"],
|
|
40
|
+
"http_path": route_info["path"],
|
|
41
|
+
"framework": Framework.FLASK,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def _parse_route_decorator(self, decorator: cst.Decorator) -> dict[str, str] | None:
|
|
48
|
+
if not isinstance(decorator.decorator, cst.Call):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
call = decorator.decorator
|
|
52
|
+
|
|
53
|
+
if isinstance(call.func, cst.Attribute):
|
|
54
|
+
if call.func.attr.value not in self.ROUTE_DECORATOR_NAMES:
|
|
55
|
+
return None
|
|
56
|
+
elif isinstance(call.func, cst.Name):
|
|
57
|
+
if call.func.value not in self.ROUTE_DECORATOR_NAMES:
|
|
58
|
+
return None
|
|
59
|
+
else:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
path = None
|
|
63
|
+
if call.args:
|
|
64
|
+
first_arg = call.args[0]
|
|
65
|
+
if isinstance(first_arg.value, cst.SimpleString):
|
|
66
|
+
path = first_arg.value.evaluated_value
|
|
67
|
+
elif isinstance(first_arg.value, cst.ConcatenatedString):
|
|
68
|
+
parts = []
|
|
69
|
+
for part in first_arg.value.left, first_arg.value.right:
|
|
70
|
+
if isinstance(part, cst.SimpleString):
|
|
71
|
+
parts.append(part.evaluated_value)
|
|
72
|
+
path = "".join(parts) if parts else None
|
|
73
|
+
|
|
74
|
+
methods = ["GET"]
|
|
75
|
+
for arg in call.args:
|
|
76
|
+
if arg.keyword and arg.keyword.value == "methods":
|
|
77
|
+
methods = self._extract_methods(arg.value)
|
|
78
|
+
|
|
79
|
+
if path:
|
|
80
|
+
return {"path": path, "method": methods[0] if methods else "GET"}
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _extract_methods(self, value: cst.BaseExpression) -> list[str]:
|
|
84
|
+
"""
|
|
85
|
+
Extract HTTP methods from a list or tuple.
|
|
86
|
+
|
|
87
|
+
Handles both Flask-style lists and Flask-AppBuilder-style tuples:
|
|
88
|
+
- methods=["GET", "POST"]
|
|
89
|
+
- methods=("GET", "POST")
|
|
90
|
+
"""
|
|
91
|
+
methods: list[str] = []
|
|
92
|
+
if isinstance(value, cst.List | cst.Tuple):
|
|
93
|
+
for el in value.elements:
|
|
94
|
+
if isinstance(el, cst.Element) and isinstance(el.value, cst.SimpleString):
|
|
95
|
+
extracted = el.value.evaluated_value
|
|
96
|
+
if extracted:
|
|
97
|
+
methods.append(extracted)
|
|
98
|
+
return methods if methods else ["GET"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FlaskErrorHandlerVisitor(cst.CSTVisitor):
|
|
102
|
+
"""Detects Flask error handlers (@app.errorhandler, @blueprint.errorhandler)."""
|
|
103
|
+
|
|
104
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
105
|
+
|
|
106
|
+
def __init__(self, file_path: str) -> None:
|
|
107
|
+
self.file_path = file_path
|
|
108
|
+
self.handlers: list[GlobalHandler] = []
|
|
109
|
+
|
|
110
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
111
|
+
for decorator in node.decorators:
|
|
112
|
+
handler_info = self._parse_errorhandler_decorator(decorator)
|
|
113
|
+
if handler_info:
|
|
114
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
115
|
+
self.handlers.append(
|
|
116
|
+
GlobalHandler(
|
|
117
|
+
file=self.file_path,
|
|
118
|
+
line=pos.start.line,
|
|
119
|
+
function=node.name.value,
|
|
120
|
+
handled_type=handler_info["exception_type"],
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
def _parse_errorhandler_decorator(self, decorator: cst.Decorator) -> dict[str, str] | None:
|
|
126
|
+
if not isinstance(decorator.decorator, cst.Call):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
call = decorator.decorator
|
|
130
|
+
|
|
131
|
+
if isinstance(call.func, cst.Attribute):
|
|
132
|
+
if call.func.attr.value != "errorhandler":
|
|
133
|
+
return None
|
|
134
|
+
elif isinstance(call.func, cst.Name):
|
|
135
|
+
if call.func.value != "errorhandler":
|
|
136
|
+
return None
|
|
137
|
+
else:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if not call.args:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
first_arg = call.args[0].value
|
|
144
|
+
exception_type = self._get_name_from_expr(first_arg)
|
|
145
|
+
if exception_type:
|
|
146
|
+
return {"exception_type": exception_type}
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _get_name_from_expr(self, expr: cst.BaseExpression) -> str:
|
|
150
|
+
if isinstance(expr, cst.Name):
|
|
151
|
+
return expr.value
|
|
152
|
+
elif isinstance(expr, cst.Attribute):
|
|
153
|
+
base = self._get_name_from_expr(expr.value)
|
|
154
|
+
if base:
|
|
155
|
+
return f"{base}.{expr.attr.value}"
|
|
156
|
+
return expr.attr.value
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def detect_flask_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
|
|
161
|
+
"""Detect Flask route entrypoints in a Python source file."""
|
|
162
|
+
try:
|
|
163
|
+
module = cst.parse_module(source)
|
|
164
|
+
except Exception:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
wrapper = MetadataWrapper(module)
|
|
168
|
+
visitor = FlaskRouteVisitor(file_path)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
wrapper.visit(visitor)
|
|
172
|
+
return visitor.entrypoints
|
|
173
|
+
except Exception:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHandler]:
|
|
178
|
+
"""Detect Flask error handlers in a Python source file."""
|
|
179
|
+
try:
|
|
180
|
+
module = cst.parse_module(source)
|
|
181
|
+
except Exception:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
wrapper = MetadataWrapper(module)
|
|
185
|
+
visitor = FlaskErrorHandlerVisitor(file_path)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
wrapper.visit(visitor)
|
|
189
|
+
return visitor.handlers
|
|
190
|
+
except Exception:
|
|
191
|
+
return []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Flask exception-to-HTTP-response mappings.
|
|
2
|
+
|
|
3
|
+
Defines which exceptions Flask converts to HTTP responses automatically.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
EXCEPTION_RESPONSES: dict[str, str] = {
|
|
7
|
+
"werkzeug.exceptions.HTTPException": "HTTP {code}",
|
|
8
|
+
"HTTPException": "HTTP {code}",
|
|
9
|
+
"werkzeug.exceptions.NotFound": "HTTP 404",
|
|
10
|
+
"NotFound": "HTTP 404",
|
|
11
|
+
"werkzeug.exceptions.BadRequest": "HTTP 400",
|
|
12
|
+
"BadRequest": "HTTP 400",
|
|
13
|
+
"werkzeug.exceptions.Unauthorized": "HTTP 401",
|
|
14
|
+
"Unauthorized": "HTTP 401",
|
|
15
|
+
"werkzeug.exceptions.Forbidden": "HTTP 403",
|
|
16
|
+
"Forbidden": "HTTP 403",
|
|
17
|
+
"werkzeug.exceptions.InternalServerError": "HTTP 500",
|
|
18
|
+
"InternalServerError": "HTTP 500",
|
|
19
|
+
}
|