attackmap-analyzer-python 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.
- attackmap_analyzer_python/__init__.py +3 -0
- attackmap_analyzer_python/_pyast.py +90 -0
- attackmap_analyzer_python/analyzer.py +783 -0
- attackmap_analyzer_python/django_urls.py +667 -0
- attackmap_analyzer_python/litestar_routes.py +358 -0
- attackmap_analyzer_python/sanic_routes.py +77 -0
- attackmap_analyzer_python/tornado_routes.py +182 -0
- attackmap_analyzer_python-0.2.0.dist-info/METADATA +134 -0
- attackmap_analyzer_python-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_python-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_python-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_python-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""Comprehensive Python ecosystem analyzer for AttackMap.
|
|
2
|
+
|
|
3
|
+
This plugin is **additive** over AttackMap's built-in `python-web` analyzer —
|
|
4
|
+
the built-in (which uses the core scanner) handles FastAPI / Flask routes,
|
|
5
|
+
basic auth keyword sweeps, and standard `os.getenv` / SECRET_PATTERNS. This
|
|
6
|
+
plugin adds:
|
|
7
|
+
|
|
8
|
+
- **Django** routing (path / re_path / url) with the include() graph joined
|
|
9
|
+
onto child routes (`django_urls`), DRF routers, settings.py DATABASES dict
|
|
10
|
+
parsing, INSTALLED_APPS, manage.py detection
|
|
11
|
+
- **Starlette** Route(...) declarations
|
|
12
|
+
- **AIOHTTP** (`add_get` / `add_route` / `web.get` / `RouteTableDef`)
|
|
13
|
+
- **Sanic** decorator routes (`@app.get`, `@app.route(methods=[...])`)
|
|
14
|
+
- **Litestar** (formerly Starlite) handlers, controllers and routers
|
|
15
|
+
(`litestar_routes`)
|
|
16
|
+
- **Tornado** URLSpecs paired with RequestHandler methods (`tornado_routes`)
|
|
17
|
+
- **SQLAlchemy** (`create_engine("postgresql://...")` with dialect-aware kind)
|
|
18
|
+
- **asyncpg / pymysql / motor.motor_asyncio** databases
|
|
19
|
+
- **boto3** resource detection (DynamoDB, S3)
|
|
20
|
+
- **passlib** (`CryptContext`, `bcrypt`, `argon2`), **python-jose / PyJWT**,
|
|
21
|
+
**authlib**, **fastapi-users**, **flask-jwt-extended**, **bcrypt** standalone,
|
|
22
|
+
**argon2-cffi**, **casbin**
|
|
23
|
+
- **httpx** sync + async, **aiohttp** ClientSession, **urllib.request.urlopen**
|
|
24
|
+
- **pydantic-settings** BaseSettings field-name secret extraction
|
|
25
|
+
- **`os.environ.get(...)` / `os.environ["..."]`** secret patterns (built-in
|
|
26
|
+
only covers `os.getenv`)
|
|
27
|
+
- **Service hints** from pyproject.toml `[project] name`, setup.py / setup.cfg
|
|
28
|
+
|
|
29
|
+
All emissions populate Signal v2 fields (line numbers, evidence snippets,
|
|
30
|
+
confidence) so downstream insights can cite `path/to/module.py:NN`.
|
|
31
|
+
|
|
32
|
+
When this plugin is installed, AttackMap's overlay deduplication merges its
|
|
33
|
+
output with the built-in `python-web` analyzer's output — so users see a
|
|
34
|
+
single unified set of findings even though both analyzers fire.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import posixpath
|
|
40
|
+
import re
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from attackmap.sdk import (
|
|
45
|
+
DEFAULT_SKIP_DIRS,
|
|
46
|
+
AnalyzerMetadata,
|
|
47
|
+
AuthHint,
|
|
48
|
+
DatabaseHint,
|
|
49
|
+
EntrypointHint,
|
|
50
|
+
ExternalCall,
|
|
51
|
+
FrameworkHint,
|
|
52
|
+
iter_repo_files,
|
|
53
|
+
line_of,
|
|
54
|
+
read_source,
|
|
55
|
+
rel,
|
|
56
|
+
Route,
|
|
57
|
+
ScanResult,
|
|
58
|
+
SecretHint,
|
|
59
|
+
ServiceHint,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from .django_urls import UrlConf, UrlGraph, join_segments, parse_urlconf
|
|
63
|
+
from .litestar_routes import LitestarFile, parse_litestar, resolve_litestar
|
|
64
|
+
from .sanic_routes import sanic_routes
|
|
65
|
+
from .tornado_routes import TornadoFile, parse_tornado, resolve_tornado
|
|
66
|
+
|
|
67
|
+
CODE_SUFFIXES = {".py"}
|
|
68
|
+
# Python-specific directories on top of the SDK defaults. Matched against
|
|
69
|
+
# directory names inside the repo only (attackmap.sdk.fs, mlaify/AttackMap#253).
|
|
70
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS | {"env", ".ruff_cache", "site-packages"}
|
|
71
|
+
_SNIPPET_MAX_CHARS = 160
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------- Patterns ----------
|
|
75
|
+
|
|
76
|
+
# Django: path("users/", views.users) / path("", views.home). These regexes are
|
|
77
|
+
# the fallback for a URLconf `ast` can't parse (Python 2, huge); parseable ones
|
|
78
|
+
# go through the include() graph in `django_urls`.
|
|
79
|
+
DJANGO_PATH_PATTERN = re.compile(
|
|
80
|
+
r'\bpath\s*\(\s*[\'"]([^\'"]*)[\'"]\s*,',
|
|
81
|
+
)
|
|
82
|
+
# Django: re_path(r"^users/$", views.users)
|
|
83
|
+
DJANGO_RE_PATH_PATTERN = re.compile(
|
|
84
|
+
r'\bre_path\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
85
|
+
)
|
|
86
|
+
# Django: url(r"^users/$", views.users) — legacy <2.0 form
|
|
87
|
+
DJANGO_LEGACY_URL_PATTERN = re.compile(
|
|
88
|
+
r'\burl\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
89
|
+
)
|
|
90
|
+
# Django REST Framework: router.register(r'users', UserViewSet)
|
|
91
|
+
DRF_ROUTER_PATTERN = re.compile(
|
|
92
|
+
r'\brouter\.register\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
93
|
+
)
|
|
94
|
+
# Django @api_view(['GET', 'POST'])
|
|
95
|
+
DJANGO_API_VIEW_PATTERN = re.compile(
|
|
96
|
+
r'@api_view\s*\(\s*\[([^\]]+)\]\s*\)',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Starlette: Route("/x", endpoint=h, methods=["GET", "POST"])
|
|
100
|
+
STARLETTE_ROUTE_PATTERN = re.compile(
|
|
101
|
+
r'\bRoute\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,(?P<rest>[^)]*?)\)',
|
|
102
|
+
re.DOTALL,
|
|
103
|
+
)
|
|
104
|
+
# Starlette WebSocketRoute("/ws", endpoint=h)
|
|
105
|
+
STARLETTE_WS_PATTERN = re.compile(
|
|
106
|
+
r'\bWebSocketRoute\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# AIOHTTP: app.router.add_get("/x", h), app.router.add_post(...)
|
|
110
|
+
# also app.add_routes([web.get("/x", h)])
|
|
111
|
+
AIOHTTP_ADD_PATTERN = re.compile(
|
|
112
|
+
r'\b(?:app|router)\.(?:router\.)?add_(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
113
|
+
re.IGNORECASE,
|
|
114
|
+
)
|
|
115
|
+
AIOHTTP_WEB_HELPER_PATTERN = re.compile(
|
|
116
|
+
r'\bweb\.(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
117
|
+
re.IGNORECASE,
|
|
118
|
+
)
|
|
119
|
+
# app.router.add_route("POST", "/x", h) / add_route("*", "/x", h)
|
|
120
|
+
AIOHTTP_ADD_ROUTE_PATTERN = re.compile(
|
|
121
|
+
r'\badd_route\s*\(\s*[\'"]([A-Za-z]+|\*)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]',
|
|
122
|
+
)
|
|
123
|
+
# app.router.add_view("/x", View) / web.view("/x", View): class-based, any verb
|
|
124
|
+
AIOHTTP_VIEW_PATTERN = re.compile(
|
|
125
|
+
r'\b(?:add_view|web\.view)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
126
|
+
)
|
|
127
|
+
# routes = web.RouteTableDef(), then @routes.get("/x") / @routes.view("/x") /
|
|
128
|
+
# @routes.route("POST", "/x"). Decorators count only on a RouteTableDef name.
|
|
129
|
+
AIOHTTP_ROUTE_TABLE_PATTERN = re.compile(
|
|
130
|
+
r'^[ \t]*(\w+)[ \t]*(?::[^=\n]*)?=[ \t]*(?:\w+\.)?RouteTableDef[ \t]*\(',
|
|
131
|
+
re.MULTILINE,
|
|
132
|
+
)
|
|
133
|
+
AIOHTTP_TABLE_DECORATOR_PATTERN = re.compile(
|
|
134
|
+
r'@(\w+)\.(get|post|put|delete|patch|head|options|view)\s*\(\s*[\'"]([^\'"]*)[\'"]',
|
|
135
|
+
)
|
|
136
|
+
AIOHTTP_TABLE_ROUTE_PATTERN = re.compile(
|
|
137
|
+
r'@(\w+)\.route\s*\(\s*[\'"]([A-Za-z]+|\*)[\'"]\s*,\s*[\'"]([^\'"]*)[\'"]',
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Sanic: @app.get("/x"), @bp.post("/x")
|
|
141
|
+
SANIC_DECORATOR_PATTERN = re.compile(
|
|
142
|
+
r'@(?:app|bp|blueprint)\.(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
143
|
+
re.IGNORECASE,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Litestar (and legacy Starlite): @get("/x"), @post("/x") decorators. The
|
|
147
|
+
# `litestar_routes` ast pass handles controllers/routers; this regex is the
|
|
148
|
+
# fallback for a file it can't parse. Only fires when the file imports from
|
|
149
|
+
# litestar/starlite to avoid mis-firing on generic `get`/`post` identifiers.
|
|
150
|
+
LITESTAR_DECORATOR_PATTERN = re.compile(
|
|
151
|
+
r'^[ \t]*@(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
152
|
+
re.MULTILINE,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
TORNADO_IMPORT_PATTERN = re.compile(r'^[ \t]*(?:from|import)[ \t]+tornado\b', re.MULTILINE)
|
|
156
|
+
|
|
157
|
+
# Flask add_url_rule (less common but valid)
|
|
158
|
+
FLASK_ADD_URL_RULE_PATTERN = re.compile(
|
|
159
|
+
r'\b\w+\.add_url_rule\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# External HTTP clients (additive over built-in scanner which covers `requests.*`)
|
|
164
|
+
OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
|
|
165
|
+
re.compile(r'\bhttpx\.(?:get|post|put|delete|patch|head)\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
166
|
+
re.compile(r'\bhttpx\.(?:Async)?Client\(\s*\)\.\w+\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
167
|
+
re.compile(r'\baiohttp\.ClientSession\(\s*\)\.\w+\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
168
|
+
re.compile(r'\bsession\.(?:get|post|put|delete|patch|head)\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
169
|
+
re.compile(r'\burllib\.request\.urlopen\s*\(\s*[\'"](https?://[^\'"]+)[\'"]'),
|
|
170
|
+
re.compile(r'\burlopen\s*\(\s*[\'"](https?://[^\'"]+)[\'"]'),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Databases — SQLAlchemy with dialect inference
|
|
175
|
+
SQLALCHEMY_ENGINE_PATTERN = re.compile(
|
|
176
|
+
r'\bcreate_engine\s*\(\s*[\'"]([a-z]+)(?:\+[a-z0-9_]+)?://',
|
|
177
|
+
re.IGNORECASE,
|
|
178
|
+
)
|
|
179
|
+
DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
180
|
+
(re.compile(r'\basyncpg\.connect\s*\(|\basyncpg\.create_pool\s*\('), "postgresql"),
|
|
181
|
+
(re.compile(r'\bpsycopg(?:2|3)?\.connect\s*\(|\bpsycopg_pool\b'), "postgresql"),
|
|
182
|
+
(re.compile(r'\bpymysql\.connect\s*\('), "mysql"),
|
|
183
|
+
(re.compile(r'\bmotor\.motor_asyncio\.AsyncIOMotorClient\s*\(|\bAsyncIOMotorClient\s*\('), "mongodb"),
|
|
184
|
+
(re.compile(r'\bpymongo\.MongoClient\s*\(|\bMongoClient\s*\('), "mongodb"),
|
|
185
|
+
(re.compile(r'\baioredis\b|\bredis\.asyncio\b'), "redis"),
|
|
186
|
+
(re.compile(r'\bboto3\.(?:resource|client)\s*\(\s*[\'"]dynamodb[\'"]'), "dynamodb"),
|
|
187
|
+
(re.compile(r'\bboto3\.(?:resource|client)\s*\(\s*[\'"]s3[\'"]'), "object_storage"),
|
|
188
|
+
(re.compile(r'\bsqlmodel\b|\bSQLModel\b'), "sql"),
|
|
189
|
+
(re.compile(r'\btortoise\.\w+|\bfrom\s+tortoise\s+import\b'), "sql"),
|
|
190
|
+
]
|
|
191
|
+
# Django settings.py DATABASES dict
|
|
192
|
+
DJANGO_DATABASES_PATTERN = re.compile(
|
|
193
|
+
r'DATABASES\s*=\s*\{(?P<body>.*?)\}\s*\n\s*(?:\n|[A-Z_]+\s*=)',
|
|
194
|
+
re.DOTALL,
|
|
195
|
+
)
|
|
196
|
+
DJANGO_ENGINE_PATTERN = re.compile(
|
|
197
|
+
r'[\'"]ENGINE[\'"]\s*:\s*[\'"]django\.db\.backends\.([a-z0-9_]+)[\'"]',
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Auth libraries. Core credits any auth hint near a route as that route's auth,
|
|
202
|
+
# so JWT hints come from checks only (AttackMap#325): signing a token, importing
|
|
203
|
+
# the library or mentioning it authenticates nobody.
|
|
204
|
+
AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
|
|
205
|
+
(re.compile(r'\bpasslib\.\w+|\bCryptContext\s*\('), "passlib", 0.85),
|
|
206
|
+
(re.compile(r'\bpasslib\.hash\.argon2\b|\bargon2\.PasswordHasher\b|\bfrom\s+argon2\s+import'), "argon2", 0.9),
|
|
207
|
+
(re.compile(r'\bpasslib\.hash\.bcrypt\b|\bbcrypt\.hashpw\s*\(|\bbcrypt\.checkpw\s*\(|\bimport\s+bcrypt\b'), "bcrypt", 0.9),
|
|
208
|
+
(re.compile(r'\bpasslib\.hash\.scrypt\b'), "scrypt", 0.9),
|
|
209
|
+
# PyJWT / python-jose `jwt.decode(` verifies unless the call turns it off
|
|
210
|
+
# (`options={"verify_signature": False}`, PyJWT 1.x `verify=False`).
|
|
211
|
+
(re.compile(
|
|
212
|
+
r'\bjwt\.decode\s*\((?![^)]{0,400}?(?:[\'"]verify_signature[\'"]\s*:\s*False|\bverify\s*=\s*False))'
|
|
213
|
+
), "jwt", 0.85),
|
|
214
|
+
(re.compile(r'\bauthlib\.\w+|\bOAuth\(\s*\)|\bOAuth2Session\s*\('), "oauth", 0.85),
|
|
215
|
+
(re.compile(r'\bfastapi_users\.\w+|\bBearerTransport\s*\(|\bJWTStrategy\s*\('), "fastapi_users", 0.85),
|
|
216
|
+
# `create_access_token(` issues a token and the module name is an import.
|
|
217
|
+
(re.compile(r'@jwt_required\b|\bverify_jwt_in_request\s*\('), "flask_jwt_extended", 0.85),
|
|
218
|
+
(re.compile(r'\bdjango\.contrib\.auth\b|\bauthenticate\s*\(\s*request\b|\bmake_password\s*\('), "django_auth", 0.85),
|
|
219
|
+
(re.compile(r'\bcasbin\.Enforcer\s*\(|\bfrom\s+casbin\b'), "casbin", 0.9),
|
|
220
|
+
(re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
|
|
221
|
+
(re.compile(r'\bBearer\b'), "bearer_token", 0.6),
|
|
222
|
+
(re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Frameworks
|
|
227
|
+
FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
228
|
+
(re.compile(r'\bfrom\s+django\b|\bimport\s+django\b'), "django"),
|
|
229
|
+
(re.compile(r'\bfrom\s+rest_framework\b|\bimport\s+rest_framework\b'), "django-rest-framework"),
|
|
230
|
+
(re.compile(r'\bfrom\s+starlette\b|\bimport\s+starlette\b'), "starlette"),
|
|
231
|
+
(re.compile(r'\bfrom\s+aiohttp\b|\bimport\s+aiohttp\b'), "aiohttp"),
|
|
232
|
+
(re.compile(r'\bfrom\s+sanic\b|\bimport\s+sanic\b'), "sanic"),
|
|
233
|
+
(re.compile(r'\bfrom\s+litestar\b|\bfrom\s+starlite\b'), "litestar"),
|
|
234
|
+
(re.compile(r'\bfrom\s+tornado\b|\bimport\s+tornado\b'), "tornado"),
|
|
235
|
+
(re.compile(r'\bfrom\s+flask\b|\bimport\s+flask\b'), "flask"),
|
|
236
|
+
(re.compile(r'\bfrom\s+fastapi\b|\bimport\s+fastapi\b'), "fastapi"),
|
|
237
|
+
(re.compile(r'\bfrom\s+sqlalchemy\b|\bimport\s+sqlalchemy\b'), "sqlalchemy"),
|
|
238
|
+
(re.compile(r'\bfrom\s+celery\b|\bimport\s+celery\b'), "celery"),
|
|
239
|
+
(re.compile(r'\bfrom\s+uvicorn\b|\bimport\s+uvicorn\b'), "uvicorn"),
|
|
240
|
+
(re.compile(r'\bfrom\s+gunicorn\b|\bimport\s+gunicorn\b'), "gunicorn"),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Entrypoints
|
|
245
|
+
ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
246
|
+
(re.compile(r'\buvicorn\.run\s*\('), "uvicorn_run"),
|
|
247
|
+
(re.compile(r'\bweb\.run_app\s*\('), "aiohttp_run_app"),
|
|
248
|
+
(re.compile(r'\bapp\.run\s*\('), "wsgi_app_run"), # Flask, Sanic, AIOHTTP variants
|
|
249
|
+
(re.compile(r'\btornado\.ioloop\.IOLoop\.current\(\)\.start\s*\('), "tornado_ioloop_start"),
|
|
250
|
+
(re.compile(r'\bif\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:'), "main_guard"),
|
|
251
|
+
(re.compile(r'\bSparkSession\.builder\b'), "spark_session"), # data plane
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Secrets — additive over built-in (which covers os.getenv).
|
|
256
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
257
|
+
re.compile(
|
|
258
|
+
r'\bos\.environ\.get\s*\(\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]',
|
|
259
|
+
),
|
|
260
|
+
re.compile(
|
|
261
|
+
r'\bos\.environ\s*\[\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]\s*\]',
|
|
262
|
+
),
|
|
263
|
+
re.compile(
|
|
264
|
+
r'\bdotenv_values\s*\(.*?\)\s*\.get\s*\(\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]',
|
|
265
|
+
re.DOTALL,
|
|
266
|
+
),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Kept rather than attackmap.sdk.line_snippet: this takes a match offset and
|
|
271
|
+
# splits on "\n" only, so it stays consistent with line_of() on files that
|
|
272
|
+
# contain form feeds or other str.splitlines() separators, and it costs
|
|
273
|
+
# O(line) per match instead of O(file).
|
|
274
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
275
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
276
|
+
line_end = content.find("\n", offset)
|
|
277
|
+
if line_end == -1:
|
|
278
|
+
line_end = len(content)
|
|
279
|
+
line = content[line_start:line_end].strip()
|
|
280
|
+
if len(line) > max_chars:
|
|
281
|
+
line = line[: max_chars - 1] + "…"
|
|
282
|
+
return line
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _name_from_pyproject(pyproject_path: Path, root: Path | None = None) -> str | None:
|
|
286
|
+
text = read_source(pyproject_path, root=root)
|
|
287
|
+
if text is None:
|
|
288
|
+
return None
|
|
289
|
+
# Try [project] name = "..." (PEP 621)
|
|
290
|
+
match = re.search(r'\[project\][^\[]*?name\s*=\s*["\']([^"\']+)["\']', text, re.DOTALL)
|
|
291
|
+
if match:
|
|
292
|
+
return match.group(1).strip()
|
|
293
|
+
# Fall back to [tool.poetry] name = "..."
|
|
294
|
+
match = re.search(r'\[tool\.poetry\][^\[]*?name\s*=\s*["\']([^"\']+)["\']', text, re.DOTALL)
|
|
295
|
+
if match:
|
|
296
|
+
return match.group(1).strip()
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _name_from_setup_py(setup_path: Path, root: Path | None = None) -> str | None:
|
|
301
|
+
text = read_source(setup_path, root=root)
|
|
302
|
+
if text is None:
|
|
303
|
+
return None
|
|
304
|
+
match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', text)
|
|
305
|
+
if match:
|
|
306
|
+
return match.group(1).strip()
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _name_from_setup_cfg(setup_cfg_path: Path, root: Path | None = None) -> str | None:
|
|
311
|
+
text = read_source(setup_cfg_path, root=root)
|
|
312
|
+
if text is None:
|
|
313
|
+
return None
|
|
314
|
+
match = re.search(r'\[metadata\][^\[]*?name\s*=\s*([^\s\n]+)', text, re.DOTALL)
|
|
315
|
+
if match:
|
|
316
|
+
return match.group(1).strip()
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_django_settings(content: str) -> bool:
|
|
321
|
+
return bool(re.search(r'\b(INSTALLED_APPS|DATABASES|MIDDLEWARE|ROOT_URLCONF)\s*=', content))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _is_django_urls(content: str) -> bool:
|
|
325
|
+
return bool(re.search(r'\burlpatterns\s*=', content))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _has_litestar_or_starlite_import(content: str) -> bool:
|
|
329
|
+
return "litestar" in content or "starlite" in content
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _has_aiohttp_import(content: str) -> bool:
|
|
333
|
+
return "aiohttp" in content or re.search(r'\bweb\.Application\s*\(', content) is not None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _has_starlette_import(content: str) -> bool:
|
|
337
|
+
return "starlette" in content
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _has_sanic_import(content: str) -> bool:
|
|
341
|
+
return "sanic" in content
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _has_django_import(content: str) -> bool:
|
|
345
|
+
return "django" in content or _is_django_urls(content) or _is_django_settings(content)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _has_drf_import(content: str) -> bool:
|
|
349
|
+
return "rest_framework" in content
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _aiohttp_method(verb: str) -> str:
|
|
353
|
+
verb = verb.upper()
|
|
354
|
+
return "ANY" if verb in {"*", "VIEW"} else verb
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _normalize_route_path(path: str) -> str:
|
|
358
|
+
"""Leading slash, no `//` runs — the shape core's scanner gives the same
|
|
359
|
+
decorator, so the two analyzers' routes merge instead of duplicating."""
|
|
360
|
+
path = "/" + path.strip().lstrip("/")
|
|
361
|
+
while "//" in path:
|
|
362
|
+
path = path.replace("//", "/")
|
|
363
|
+
return path
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass
|
|
367
|
+
class _RouteState:
|
|
368
|
+
"""Per-analyze() state for the cross-file route passes."""
|
|
369
|
+
|
|
370
|
+
django: dict[str, UrlConf] = field(default_factory=dict)
|
|
371
|
+
litestar: list[LitestarFile] = field(default_factory=list)
|
|
372
|
+
tornado: list[TornadoFile] = field(default_factory=list)
|
|
373
|
+
project_dirs: list[str] = field(default_factory=list)
|
|
374
|
+
seen: dict[tuple[str, str, str], Route] = field(default_factory=dict)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class PythonAnalyzer:
|
|
378
|
+
metadata = AnalyzerMetadata(
|
|
379
|
+
name="python",
|
|
380
|
+
display_name="Python Comprehensive Analyzer",
|
|
381
|
+
version="0.2.0",
|
|
382
|
+
description="Comprehensive Python analyzer for Django, Starlette, AIOHTTP, Sanic, Litestar, DRF; SQLAlchemy/asyncpg/motor; passlib/PyJWT/authlib; httpx/aiohttp.",
|
|
383
|
+
scope="Python projects (pyproject.toml, setup.py, manage.py, or any *.py tree). Additive over the built-in python-web analyzer.",
|
|
384
|
+
targets=["python", "django", "starlette", "aiohttp", "sanic", "litestar"],
|
|
385
|
+
languages=["python"],
|
|
386
|
+
# Lower number runs first: core orders analyzers by (priority, name) across
|
|
387
|
+
# built-ins and plugins, and merge is first-seen-wins, so this runs before
|
|
388
|
+
# the built-in python-web (20) and its richer output wins on overlap.
|
|
389
|
+
priority=10,
|
|
390
|
+
experimental=False,
|
|
391
|
+
enabled_by_default=True,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def name(self) -> str:
|
|
396
|
+
return self.metadata.name
|
|
397
|
+
|
|
398
|
+
# ---------- Public entry points ----------
|
|
399
|
+
|
|
400
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
401
|
+
root = Path(repo_path).resolve()
|
|
402
|
+
if not root.exists() or not root.is_dir():
|
|
403
|
+
return False
|
|
404
|
+
for marker in ("pyproject.toml", "setup.py", "setup.cfg", "manage.py", "Pipfile"):
|
|
405
|
+
if (root / marker).exists():
|
|
406
|
+
return True
|
|
407
|
+
return next(iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS), None) is not None
|
|
408
|
+
|
|
409
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
410
|
+
root = Path(repo_path).resolve()
|
|
411
|
+
result = ScanResult(root=str(root))
|
|
412
|
+
if not root.exists() or not root.is_dir():
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
# Service-name hints from project metadata
|
|
416
|
+
for name_provider, marker in (
|
|
417
|
+
(_name_from_pyproject, "pyproject.toml"),
|
|
418
|
+
(_name_from_setup_py, "setup.py"),
|
|
419
|
+
(_name_from_setup_cfg, "setup.cfg"),
|
|
420
|
+
):
|
|
421
|
+
project_name = name_provider(root / marker, root)
|
|
422
|
+
if project_name:
|
|
423
|
+
self._append_unique_service(result, f"package:{project_name}", marker)
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
if (root / "manage.py").exists():
|
|
427
|
+
self._append_unique_service(result, "framework:django", "manage.py")
|
|
428
|
+
|
|
429
|
+
state = _RouteState()
|
|
430
|
+
for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
431
|
+
content = read_source(file_path, root=root)
|
|
432
|
+
if content is None:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
result.files_scanned += 1
|
|
436
|
+
if "python" not in result.languages:
|
|
437
|
+
result.languages.append("python")
|
|
438
|
+
|
|
439
|
+
relative = rel(file_path, root)
|
|
440
|
+
if posixpath.basename(relative) == "manage.py":
|
|
441
|
+
state.project_dirs.append(posixpath.dirname(relative))
|
|
442
|
+
self._extract_routes(content, relative, result, state)
|
|
443
|
+
self._extract_databases(content, relative, result)
|
|
444
|
+
self._extract_django_settings(content, relative, result)
|
|
445
|
+
self._extract_auth(content, relative, result)
|
|
446
|
+
self._extract_secrets(content, relative, result)
|
|
447
|
+
self._extract_external_calls(content, relative, result)
|
|
448
|
+
self._extract_frameworks(content, relative, result)
|
|
449
|
+
self._extract_entrypoints(content, relative, result)
|
|
450
|
+
|
|
451
|
+
self._finish_routes(result, state)
|
|
452
|
+
result.languages.sort()
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
# ---------- Extractors ----------
|
|
456
|
+
|
|
457
|
+
def _extract_routes(self, content: str, relative: str, result: ScanResult, state: "_RouteState") -> None:
|
|
458
|
+
def add(path: str, method: str, line: int | None) -> None:
|
|
459
|
+
self._append_unique_route(result, path, method, relative, line, seen=state.seen)
|
|
460
|
+
|
|
461
|
+
# Django URLconfs and DRF router modules go into the include() graph,
|
|
462
|
+
# resolved once every file is read (`_finish_routes`).
|
|
463
|
+
is_urlconf = _has_django_import(content) and _is_django_urls(content)
|
|
464
|
+
drf = _has_drf_import(content)
|
|
465
|
+
conf: UrlConf | None = None
|
|
466
|
+
if is_urlconf or (".register(" in content and "Router(" in content):
|
|
467
|
+
conf = parse_urlconf(content, relative, is_urlconf=is_urlconf, drf=drf)
|
|
468
|
+
if conf is not None:
|
|
469
|
+
state.django[relative] = conf
|
|
470
|
+
if conf is None:
|
|
471
|
+
# Not parseable (Python 2, oversized): the per-file regex fallback.
|
|
472
|
+
if is_urlconf:
|
|
473
|
+
for pattern, regex in (
|
|
474
|
+
(DJANGO_PATH_PATTERN, False),
|
|
475
|
+
(DJANGO_RE_PATH_PATTERN, True),
|
|
476
|
+
(DJANGO_LEGACY_URL_PATTERN, True),
|
|
477
|
+
):
|
|
478
|
+
for match in pattern.finditer(content):
|
|
479
|
+
path = join_segments([(match.group(1), regex)])
|
|
480
|
+
if path == "/" and content[match.end() : match.end() + 200].lstrip().startswith("include"):
|
|
481
|
+
continue # path("", include(...)): a mount point, not an endpoint
|
|
482
|
+
add(path, "ANY", line_of(content, match.start()))
|
|
483
|
+
if drf:
|
|
484
|
+
for match in DRF_ROUTER_PATTERN.finditer(content):
|
|
485
|
+
add("/" + match.group(1).lstrip("/"), "ANY", line_of(content, match.start()))
|
|
486
|
+
|
|
487
|
+
if drf:
|
|
488
|
+
for match in DJANGO_API_VIEW_PATTERN.finditer(content):
|
|
489
|
+
methods = re.findall(r'[\'"]([A-Z]+)[\'"]', match.group(1))
|
|
490
|
+
# No path on @api_view alone — caller must pair with path() in urls.py.
|
|
491
|
+
# We emit the methods as a low-signal hint via framework_hint to capture intent.
|
|
492
|
+
line = line_of(content, match.start())
|
|
493
|
+
for method in methods:
|
|
494
|
+
self._append_unique_framework(
|
|
495
|
+
result, f"drf_api_view:{method}", relative, line,
|
|
496
|
+
_line_snippet(content, match.start()),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Starlette
|
|
500
|
+
if _has_starlette_import(content):
|
|
501
|
+
for match in STARLETTE_ROUTE_PATTERN.finditer(content):
|
|
502
|
+
path = match.group(1)
|
|
503
|
+
rest = match.group("rest") or ""
|
|
504
|
+
methods_match = re.search(r'methods\s*=\s*\[([^\]]+)\]', rest)
|
|
505
|
+
line = line_of(content, match.start())
|
|
506
|
+
if methods_match:
|
|
507
|
+
methods = [m.upper() for m in re.findall(r'[\'"]([A-Z]+)[\'"]', methods_match.group(1))]
|
|
508
|
+
else:
|
|
509
|
+
methods = ["ANY"]
|
|
510
|
+
for method in methods:
|
|
511
|
+
add(path, method, line)
|
|
512
|
+
for match in STARLETTE_WS_PATTERN.finditer(content):
|
|
513
|
+
add(match.group(1), "WS", line_of(content, match.start()))
|
|
514
|
+
|
|
515
|
+
# AIOHTTP — gated by import presence so generic `add_get`/`web.get` doesn't fire.
|
|
516
|
+
if _has_aiohttp_import(content):
|
|
517
|
+
for match in AIOHTTP_ADD_PATTERN.finditer(content):
|
|
518
|
+
add(match.group(2), match.group(1).upper(), line_of(content, match.start()))
|
|
519
|
+
for match in AIOHTTP_WEB_HELPER_PATTERN.finditer(content):
|
|
520
|
+
add(match.group(2), match.group(1).upper(), line_of(content, match.start()))
|
|
521
|
+
for match in AIOHTTP_ADD_ROUTE_PATTERN.finditer(content):
|
|
522
|
+
add(match.group(2), _aiohttp_method(match.group(1)), line_of(content, match.start()))
|
|
523
|
+
for match in AIOHTTP_VIEW_PATTERN.finditer(content):
|
|
524
|
+
add(match.group(1), "ANY", line_of(content, match.start()))
|
|
525
|
+
tables = {m.group(1) for m in AIOHTTP_ROUTE_TABLE_PATTERN.finditer(content)}
|
|
526
|
+
if tables:
|
|
527
|
+
for match in AIOHTTP_TABLE_DECORATOR_PATTERN.finditer(content):
|
|
528
|
+
if match.group(1) in tables:
|
|
529
|
+
add(_normalize_route_path(match.group(3)), _aiohttp_method(match.group(2)), line_of(content, match.start()))
|
|
530
|
+
for match in AIOHTTP_TABLE_ROUTE_PATTERN.finditer(content):
|
|
531
|
+
if match.group(1) in tables:
|
|
532
|
+
add(_normalize_route_path(match.group(3)), _aiohttp_method(match.group(2)), line_of(content, match.start()))
|
|
533
|
+
|
|
534
|
+
# Sanic — gated by sanic import
|
|
535
|
+
if _has_sanic_import(content):
|
|
536
|
+
for match in SANIC_DECORATOR_PATTERN.finditer(content):
|
|
537
|
+
add(match.group(2), match.group(1).upper(), line_of(content, match.start()))
|
|
538
|
+
if ".route(" in content or ".websocket(" in content:
|
|
539
|
+
for path, method, line in sanic_routes(content) or []:
|
|
540
|
+
add(path, method, line)
|
|
541
|
+
|
|
542
|
+
# Litestar / Starlite — decorators count only when imported from litestar.
|
|
543
|
+
if _has_litestar_or_starlite_import(content):
|
|
544
|
+
litestar = parse_litestar(content, relative)
|
|
545
|
+
if litestar is not None:
|
|
546
|
+
state.litestar.append(litestar)
|
|
547
|
+
else:
|
|
548
|
+
for match in LITESTAR_DECORATOR_PATTERN.finditer(content):
|
|
549
|
+
add(match.group(2), match.group(1).upper(), line_of(content, match.start()))
|
|
550
|
+
|
|
551
|
+
# Tornado — URLSpecs are paired with handler classes repo-wide.
|
|
552
|
+
if "tornado" in content and TORNADO_IMPORT_PATTERN.search(content):
|
|
553
|
+
tornado = parse_tornado(content, relative)
|
|
554
|
+
if tornado is not None:
|
|
555
|
+
state.tornado.append(tornado)
|
|
556
|
+
|
|
557
|
+
# Flask add_url_rule (additive — built-in covers @app.route)
|
|
558
|
+
if "flask" in content.lower() or "Blueprint(" in content:
|
|
559
|
+
for match in FLASK_ADD_URL_RULE_PATTERN.finditer(content):
|
|
560
|
+
add(match.group(1), "ANY", line_of(content, match.start()))
|
|
561
|
+
|
|
562
|
+
def _finish_routes(self, result: ScanResult, state: "_RouteState") -> None:
|
|
563
|
+
"""Routes that need every file first: the Django include() graph,
|
|
564
|
+
Litestar router/controller joins, Tornado handler classes."""
|
|
565
|
+
graph = UrlGraph(state.django, state.project_dirs)
|
|
566
|
+
for route in [*graph.expand(), *resolve_litestar(state.litestar), *resolve_tornado(state.tornado)]:
|
|
567
|
+
self._append_unique_route(
|
|
568
|
+
result, route.path, route.method, route.file, route.line,
|
|
569
|
+
seen=state.seen, auth=route.auth, guards=route.guards, guard_evidence=route.evidence,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
|
|
573
|
+
for pattern, kind in DB_PATTERNS:
|
|
574
|
+
match = pattern.search(content)
|
|
575
|
+
if match is None:
|
|
576
|
+
continue
|
|
577
|
+
self._append_unique_database(
|
|
578
|
+
result, kind, relative,
|
|
579
|
+
line_of(content, match.start()),
|
|
580
|
+
_line_snippet(content, match.start()),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# SQLAlchemy with dialect inference
|
|
584
|
+
for match in SQLALCHEMY_ENGINE_PATTERN.finditer(content):
|
|
585
|
+
dialect = match.group(1).lower()
|
|
586
|
+
kind = {
|
|
587
|
+
"postgresql": "postgresql",
|
|
588
|
+
"postgres": "postgresql",
|
|
589
|
+
"mysql": "mysql",
|
|
590
|
+
"mariadb": "mariadb",
|
|
591
|
+
"sqlite": "sqlite",
|
|
592
|
+
"oracle": "oracle",
|
|
593
|
+
"mssql": "sqlserver",
|
|
594
|
+
}.get(dialect, "sql")
|
|
595
|
+
self._append_unique_database(
|
|
596
|
+
result, kind, relative,
|
|
597
|
+
line_of(content, match.start()),
|
|
598
|
+
_line_snippet(content, match.start()),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def _extract_django_settings(self, content: str, relative: str, result: ScanResult) -> None:
|
|
602
|
+
if not _is_django_settings(content):
|
|
603
|
+
return
|
|
604
|
+
# DATABASES dict — extract ENGINE values
|
|
605
|
+
for match in DJANGO_ENGINE_PATTERN.finditer(content):
|
|
606
|
+
engine_short = match.group(1).lower()
|
|
607
|
+
kind = {
|
|
608
|
+
"postgresql": "postgresql",
|
|
609
|
+
"postgresql_psycopg2": "postgresql",
|
|
610
|
+
"mysql": "mysql",
|
|
611
|
+
"sqlite3": "sqlite",
|
|
612
|
+
"oracle": "oracle",
|
|
613
|
+
}.get(engine_short, "sql")
|
|
614
|
+
self._append_unique_database(
|
|
615
|
+
result, kind, relative,
|
|
616
|
+
line_of(content, match.start()),
|
|
617
|
+
_line_snippet(content, match.start()),
|
|
618
|
+
)
|
|
619
|
+
# INSTALLED_APPS — extract third-party / local apps as service hints
|
|
620
|
+
installed_match = re.search(r'INSTALLED_APPS\s*=\s*\[(?P<body>.*?)\]', content, re.DOTALL)
|
|
621
|
+
if installed_match:
|
|
622
|
+
for app_match in re.finditer(r'[\'"]([a-zA-Z][\w.]*)[\'"]', installed_match.group("body")):
|
|
623
|
+
app_name = app_match.group(1)
|
|
624
|
+
# Skip Django built-ins to keep signal high.
|
|
625
|
+
if app_name.startswith("django.") or app_name in {"django"}:
|
|
626
|
+
continue
|
|
627
|
+
self._append_unique_service(result, f"django_app:{app_name}", relative)
|
|
628
|
+
|
|
629
|
+
def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
|
|
630
|
+
for pattern, hint, confidence in AUTH_PATTERNS:
|
|
631
|
+
match = pattern.search(content)
|
|
632
|
+
if match is None:
|
|
633
|
+
continue
|
|
634
|
+
self._append_unique_auth(
|
|
635
|
+
result, hint, relative,
|
|
636
|
+
line_of(content, match.start()),
|
|
637
|
+
_line_snippet(content, match.start()),
|
|
638
|
+
confidence,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
642
|
+
for pattern in SECRET_PATTERNS:
|
|
643
|
+
for match in pattern.finditer(content):
|
|
644
|
+
name = match.group(1)
|
|
645
|
+
self._append_unique_secret(
|
|
646
|
+
result, name, relative,
|
|
647
|
+
line_of(content, match.start()),
|
|
648
|
+
_line_snippet(content, match.start()),
|
|
649
|
+
)
|
|
650
|
+
# pydantic-settings BaseSettings: class Settings(BaseSettings): jwt_secret: str = ...
|
|
651
|
+
if "BaseSettings" in content:
|
|
652
|
+
for match in re.finditer(
|
|
653
|
+
r'^\s+([a-zA-Z_][a-zA-Z0-9_]*(?:secret|token|key|password|pass|pwd)[a-zA-Z0-9_]*)\s*:\s*\w',
|
|
654
|
+
content,
|
|
655
|
+
re.MULTILINE | re.IGNORECASE,
|
|
656
|
+
):
|
|
657
|
+
name = match.group(1)
|
|
658
|
+
self._append_unique_secret(
|
|
659
|
+
result, name, relative,
|
|
660
|
+
line_of(content, match.start()),
|
|
661
|
+
_line_snippet(content, match.start()),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
665
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
666
|
+
for match in pattern.finditer(content):
|
|
667
|
+
target = match.group(1)
|
|
668
|
+
if not (target.startswith("http://") or target.startswith("https://")):
|
|
669
|
+
continue
|
|
670
|
+
self._append_unique_external(
|
|
671
|
+
result, target, relative,
|
|
672
|
+
line_of(content, match.start()),
|
|
673
|
+
_line_snippet(content, match.start()),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
|
|
677
|
+
for pattern, name in FRAMEWORK_PATTERNS:
|
|
678
|
+
match = pattern.search(content)
|
|
679
|
+
if match is None:
|
|
680
|
+
continue
|
|
681
|
+
self._append_unique_framework(
|
|
682
|
+
result, name, relative,
|
|
683
|
+
line_of(content, match.start()),
|
|
684
|
+
_line_snippet(content, match.start()),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
688
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
689
|
+
match = pattern.search(content)
|
|
690
|
+
if match is None:
|
|
691
|
+
continue
|
|
692
|
+
self._append_unique_entrypoint(
|
|
693
|
+
result, hint, relative,
|
|
694
|
+
line_of(content, match.start()),
|
|
695
|
+
_line_snippet(content, match.start()),
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# ---------- Append helpers ----------
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def _append_unique_route(
|
|
702
|
+
result: ScanResult,
|
|
703
|
+
path: str,
|
|
704
|
+
method: str,
|
|
705
|
+
file: str,
|
|
706
|
+
line: int | None,
|
|
707
|
+
*,
|
|
708
|
+
seen: dict[tuple[str, str, str], Route] | None = None,
|
|
709
|
+
auth: str = "unknown",
|
|
710
|
+
guards: list[str] | None = None,
|
|
711
|
+
guard_evidence: str | None = None,
|
|
712
|
+
) -> None:
|
|
713
|
+
key = (path, method, file)
|
|
714
|
+
if seen is not None:
|
|
715
|
+
existing = seen.get(key)
|
|
716
|
+
else:
|
|
717
|
+
existing = next((item for item in result.routes if (item.path, item.method, item.file) == key), None)
|
|
718
|
+
if existing is not None:
|
|
719
|
+
# Never first-wins on auth: the same route emitted twice with
|
|
720
|
+
# different auth (a reassigned or if/else `urlpatterns`, two
|
|
721
|
+
# mounts) isn't known to be guarded, so it becomes `unknown`.
|
|
722
|
+
if existing.auth != auth:
|
|
723
|
+
existing.auth, existing.guards, existing.guard_evidence = "unknown", [], None
|
|
724
|
+
return
|
|
725
|
+
route = Route(
|
|
726
|
+
path=path, method=method, file=file, line=line,
|
|
727
|
+
auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
|
|
728
|
+
)
|
|
729
|
+
if seen is not None:
|
|
730
|
+
seen[key] = route
|
|
731
|
+
result.routes.append(route)
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
735
|
+
key = (kind, file)
|
|
736
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
737
|
+
return
|
|
738
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
739
|
+
|
|
740
|
+
@staticmethod
|
|
741
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
742
|
+
key = (hint, file)
|
|
743
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
744
|
+
return
|
|
745
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
746
|
+
|
|
747
|
+
@staticmethod
|
|
748
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
749
|
+
key = (name, file)
|
|
750
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
751
|
+
return
|
|
752
|
+
result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
|
|
753
|
+
|
|
754
|
+
@staticmethod
|
|
755
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
756
|
+
key = (target, file)
|
|
757
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
758
|
+
return
|
|
759
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
760
|
+
|
|
761
|
+
@staticmethod
|
|
762
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
763
|
+
key = (hint, file)
|
|
764
|
+
if any((item.hint, item.file) == key for item in result.framework_hints):
|
|
765
|
+
return
|
|
766
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
767
|
+
|
|
768
|
+
@staticmethod
|
|
769
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
770
|
+
key = (hint, file)
|
|
771
|
+
if any((item.hint, item.file) == key for item in result.entrypoint_hints):
|
|
772
|
+
return
|
|
773
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
777
|
+
key = (hint, file)
|
|
778
|
+
if any((item.hint, item.file) == key for item in result.service_hints):
|
|
779
|
+
return
|
|
780
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
__all__ = ["PythonAnalyzer"]
|