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.
@@ -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"]