attackmap-analyzer-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of attackmap-analyzer-python might be problematic. Click here for more details.
- attackmap_analyzer_python/__init__.py +3 -0
- attackmap_analyzer_python/analyzer.py +669 -0
- attackmap_analyzer_python/contracts.py +30 -0
- attackmap_analyzer_python-0.1.0.dist-info/METADATA +121 -0
- attackmap_analyzer_python-0.1.0.dist-info/RECORD +9 -0
- attackmap_analyzer_python-0.1.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_python-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,669 @@
|
|
|
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 / include / DRF routers), settings.py
|
|
9
|
+
DATABASES dict parsing, INSTALLED_APPS, manage.py detection
|
|
10
|
+
- **Starlette** Route(...) declarations
|
|
11
|
+
- **AIOHTTP** (`add_get` / `add_post` / `web.get` / `web.post`)
|
|
12
|
+
- **Sanic** decorator routes (`@app.get`, `@app.post`)
|
|
13
|
+
- **Litestar** (formerly Starlite) decorator routes
|
|
14
|
+
- **SQLAlchemy** (`create_engine("postgresql://...")` with dialect-aware kind)
|
|
15
|
+
- **asyncpg / pymysql / motor.motor_asyncio** databases
|
|
16
|
+
- **boto3** resource detection (DynamoDB, S3)
|
|
17
|
+
- **passlib** (`CryptContext`, `bcrypt`, `argon2`), **python-jose / PyJWT**,
|
|
18
|
+
**authlib**, **fastapi-users**, **flask-jwt-extended**, **bcrypt** standalone,
|
|
19
|
+
**argon2-cffi**, **casbin**
|
|
20
|
+
- **httpx** sync + async, **aiohttp** ClientSession, **urllib.request.urlopen**
|
|
21
|
+
- **pydantic-settings** BaseSettings field-name secret extraction
|
|
22
|
+
- **`os.environ.get(...)` / `os.environ["..."]`** secret patterns (built-in
|
|
23
|
+
only covers `os.getenv`)
|
|
24
|
+
- **Service hints** from pyproject.toml `[project] name`, setup.py / setup.cfg
|
|
25
|
+
|
|
26
|
+
All emissions populate Signal v2 fields (line numbers, evidence snippets,
|
|
27
|
+
confidence) so downstream insights can cite `path/to/module.py:NN`.
|
|
28
|
+
|
|
29
|
+
When this plugin is installed, AttackMap's overlay deduplication merges its
|
|
30
|
+
output with the built-in `python-web` analyzer's output — so users see a
|
|
31
|
+
single unified set of findings even though both analyzers fire.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from .contracts import (
|
|
40
|
+
AnalyzerMetadata,
|
|
41
|
+
AuthHint,
|
|
42
|
+
DatabaseHint,
|
|
43
|
+
EntrypointHint,
|
|
44
|
+
ExternalCall,
|
|
45
|
+
FrameworkHint,
|
|
46
|
+
Route,
|
|
47
|
+
ScanResult,
|
|
48
|
+
SecretHint,
|
|
49
|
+
ServiceHint,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
CODE_SUFFIXES = {".py"}
|
|
53
|
+
SKIP_DIRS = {
|
|
54
|
+
".venv",
|
|
55
|
+
"venv",
|
|
56
|
+
"env",
|
|
57
|
+
".tox",
|
|
58
|
+
"__pycache__",
|
|
59
|
+
".pytest_cache",
|
|
60
|
+
"dist",
|
|
61
|
+
"build",
|
|
62
|
+
"node_modules",
|
|
63
|
+
".git",
|
|
64
|
+
".mypy_cache",
|
|
65
|
+
".ruff_cache",
|
|
66
|
+
"site-packages",
|
|
67
|
+
}
|
|
68
|
+
_SNIPPET_MAX_CHARS = 160
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------- Patterns ----------
|
|
72
|
+
|
|
73
|
+
# Django: path("users/", views.users) / path("users/", views.users, name="users")
|
|
74
|
+
DJANGO_PATH_PATTERN = re.compile(
|
|
75
|
+
r'\bpath\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,',
|
|
76
|
+
)
|
|
77
|
+
# Django: re_path(r"^users/$", views.users)
|
|
78
|
+
DJANGO_RE_PATH_PATTERN = re.compile(
|
|
79
|
+
r'\bre_path\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
80
|
+
)
|
|
81
|
+
# Django: url(r"^users/$", views.users) — legacy <2.0 form
|
|
82
|
+
DJANGO_LEGACY_URL_PATTERN = re.compile(
|
|
83
|
+
r'\burl\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
84
|
+
)
|
|
85
|
+
# Django REST Framework: router.register(r'users', UserViewSet)
|
|
86
|
+
DRF_ROUTER_PATTERN = re.compile(
|
|
87
|
+
r'\brouter\.register\s*\(\s*r?[\'"]([^\'"]+)[\'"]\s*,',
|
|
88
|
+
)
|
|
89
|
+
# Django @api_view(['GET', 'POST'])
|
|
90
|
+
DJANGO_API_VIEW_PATTERN = re.compile(
|
|
91
|
+
r'@api_view\s*\(\s*\[([^\]]+)\]\s*\)',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Starlette: Route("/x", endpoint=h, methods=["GET", "POST"])
|
|
95
|
+
STARLETTE_ROUTE_PATTERN = re.compile(
|
|
96
|
+
r'\bRoute\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,(?P<rest>[^)]*?)\)',
|
|
97
|
+
re.DOTALL,
|
|
98
|
+
)
|
|
99
|
+
# Starlette WebSocketRoute("/ws", endpoint=h)
|
|
100
|
+
STARLETTE_WS_PATTERN = re.compile(
|
|
101
|
+
r'\bWebSocketRoute\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# AIOHTTP: app.router.add_get("/x", h), app.router.add_post(...)
|
|
105
|
+
# also app.add_routes([web.get("/x", h)])
|
|
106
|
+
AIOHTTP_ADD_PATTERN = re.compile(
|
|
107
|
+
r'\b(?:app|router)\.(?:router\.)?add_(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
108
|
+
re.IGNORECASE,
|
|
109
|
+
)
|
|
110
|
+
AIOHTTP_WEB_HELPER_PATTERN = re.compile(
|
|
111
|
+
r'\bweb\.(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
112
|
+
re.IGNORECASE,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Sanic: @app.get("/x"), @bp.post("/x")
|
|
116
|
+
SANIC_DECORATOR_PATTERN = re.compile(
|
|
117
|
+
r'@(?:app|bp|blueprint)\.(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
118
|
+
re.IGNORECASE,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Litestar (and legacy Starlite): @get("/x"), @post("/x") top-level decorators
|
|
122
|
+
# Only fire when the file imports from litestar/starlite to avoid mis-firing on
|
|
123
|
+
# generic identifiers named `get` or `post`.
|
|
124
|
+
LITESTAR_DECORATOR_PATTERN = re.compile(
|
|
125
|
+
r'^@(get|post|put|delete|patch|head|options)\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
126
|
+
re.MULTILINE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Flask add_url_rule (less common but valid)
|
|
130
|
+
FLASK_ADD_URL_RULE_PATTERN = re.compile(
|
|
131
|
+
r'\b\w+\.add_url_rule\s*\(\s*[\'"]([^\'"]+)[\'"]',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# External HTTP clients (additive over built-in scanner which covers `requests.*`)
|
|
136
|
+
OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
|
|
137
|
+
re.compile(r'\bhttpx\.(?:get|post|put|delete|patch|head)\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
138
|
+
re.compile(r'\bhttpx\.(?:Async)?Client\(\s*\)\.\w+\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
139
|
+
re.compile(r'\baiohttp\.ClientSession\(\s*\)\.\w+\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
140
|
+
re.compile(r'\bsession\.(?:get|post|put|delete|patch|head)\s*\(\s*[\'"](https?://[^\'"]+)[\'"]', re.IGNORECASE),
|
|
141
|
+
re.compile(r'\burllib\.request\.urlopen\s*\(\s*[\'"](https?://[^\'"]+)[\'"]'),
|
|
142
|
+
re.compile(r'\burlopen\s*\(\s*[\'"](https?://[^\'"]+)[\'"]'),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Databases — SQLAlchemy with dialect inference
|
|
147
|
+
SQLALCHEMY_ENGINE_PATTERN = re.compile(
|
|
148
|
+
r'\bcreate_engine\s*\(\s*[\'"]([a-z]+)(?:\+[a-z0-9_]+)?://',
|
|
149
|
+
re.IGNORECASE,
|
|
150
|
+
)
|
|
151
|
+
DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
152
|
+
(re.compile(r'\basyncpg\.connect\s*\(|\basyncpg\.create_pool\s*\('), "postgresql"),
|
|
153
|
+
(re.compile(r'\bpsycopg(?:2|3)?\.connect\s*\(|\bpsycopg_pool\b'), "postgresql"),
|
|
154
|
+
(re.compile(r'\bpymysql\.connect\s*\('), "mysql"),
|
|
155
|
+
(re.compile(r'\bmotor\.motor_asyncio\.AsyncIOMotorClient\s*\(|\bAsyncIOMotorClient\s*\('), "mongodb"),
|
|
156
|
+
(re.compile(r'\bpymongo\.MongoClient\s*\(|\bMongoClient\s*\('), "mongodb"),
|
|
157
|
+
(re.compile(r'\baioredis\b|\bredis\.asyncio\b'), "redis"),
|
|
158
|
+
(re.compile(r'\bboto3\.(?:resource|client)\s*\(\s*[\'"]dynamodb[\'"]'), "dynamodb"),
|
|
159
|
+
(re.compile(r'\bboto3\.(?:resource|client)\s*\(\s*[\'"]s3[\'"]'), "object_storage"),
|
|
160
|
+
(re.compile(r'\bsqlmodel\b|\bSQLModel\b'), "sql"),
|
|
161
|
+
(re.compile(r'\btortoise\.\w+|\bfrom\s+tortoise\s+import\b'), "sql"),
|
|
162
|
+
]
|
|
163
|
+
# Django settings.py DATABASES dict
|
|
164
|
+
DJANGO_DATABASES_PATTERN = re.compile(
|
|
165
|
+
r'DATABASES\s*=\s*\{(?P<body>.*?)\}\s*\n\s*(?:\n|[A-Z_]+\s*=)',
|
|
166
|
+
re.DOTALL,
|
|
167
|
+
)
|
|
168
|
+
DJANGO_ENGINE_PATTERN = re.compile(
|
|
169
|
+
r'[\'"]ENGINE[\'"]\s*:\s*[\'"]django\.db\.backends\.([a-z0-9_]+)[\'"]',
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Auth libraries
|
|
174
|
+
AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
|
|
175
|
+
(re.compile(r'\bpasslib\.\w+|\bCryptContext\s*\('), "passlib", 0.85),
|
|
176
|
+
(re.compile(r'\bpasslib\.hash\.argon2\b|\bargon2\.PasswordHasher\b|\bfrom\s+argon2\s+import'), "argon2", 0.9),
|
|
177
|
+
(re.compile(r'\bpasslib\.hash\.bcrypt\b|\bbcrypt\.hashpw\s*\(|\bbcrypt\.checkpw\s*\(|\bimport\s+bcrypt\b'), "bcrypt", 0.9),
|
|
178
|
+
(re.compile(r'\bpasslib\.hash\.scrypt\b'), "scrypt", 0.9),
|
|
179
|
+
(re.compile(r'\bjwt\.(?:encode|decode)\s*\(|\bfrom\s+jose\s+import\s+jwt\b|\bjose\.jwt\b'), "jwt", 0.85),
|
|
180
|
+
(re.compile(r'\bauthlib\.\w+|\bOAuth\(\s*\)|\bOAuth2Session\s*\('), "oauth", 0.85),
|
|
181
|
+
(re.compile(r'\bfastapi_users\.\w+|\bBearerTransport\s*\(|\bJWTStrategy\s*\('), "fastapi_users", 0.85),
|
|
182
|
+
(re.compile(r'@jwt_required\b|\bcreate_access_token\s*\(|\bflask_jwt_extended\b'), "flask_jwt_extended", 0.85),
|
|
183
|
+
(re.compile(r'\bdjango\.contrib\.auth\b|\bauthenticate\s*\(\s*request\b|\bmake_password\s*\('), "django_auth", 0.85),
|
|
184
|
+
(re.compile(r'\bcasbin\.Enforcer\s*\(|\bfrom\s+casbin\b'), "casbin", 0.9),
|
|
185
|
+
(re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
|
|
186
|
+
(re.compile(r'\bBearer\b'), "bearer_token", 0.6),
|
|
187
|
+
(re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Frameworks
|
|
192
|
+
FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
193
|
+
(re.compile(r'\bfrom\s+django\b|\bimport\s+django\b'), "django"),
|
|
194
|
+
(re.compile(r'\bfrom\s+rest_framework\b|\bimport\s+rest_framework\b'), "django-rest-framework"),
|
|
195
|
+
(re.compile(r'\bfrom\s+starlette\b|\bimport\s+starlette\b'), "starlette"),
|
|
196
|
+
(re.compile(r'\bfrom\s+aiohttp\b|\bimport\s+aiohttp\b'), "aiohttp"),
|
|
197
|
+
(re.compile(r'\bfrom\s+sanic\b|\bimport\s+sanic\b'), "sanic"),
|
|
198
|
+
(re.compile(r'\bfrom\s+litestar\b|\bfrom\s+starlite\b'), "litestar"),
|
|
199
|
+
(re.compile(r'\bfrom\s+tornado\b|\bimport\s+tornado\b'), "tornado"),
|
|
200
|
+
(re.compile(r'\bfrom\s+flask\b|\bimport\s+flask\b'), "flask"),
|
|
201
|
+
(re.compile(r'\bfrom\s+fastapi\b|\bimport\s+fastapi\b'), "fastapi"),
|
|
202
|
+
(re.compile(r'\bfrom\s+sqlalchemy\b|\bimport\s+sqlalchemy\b'), "sqlalchemy"),
|
|
203
|
+
(re.compile(r'\bfrom\s+celery\b|\bimport\s+celery\b'), "celery"),
|
|
204
|
+
(re.compile(r'\bfrom\s+uvicorn\b|\bimport\s+uvicorn\b'), "uvicorn"),
|
|
205
|
+
(re.compile(r'\bfrom\s+gunicorn\b|\bimport\s+gunicorn\b'), "gunicorn"),
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Entrypoints
|
|
210
|
+
ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
211
|
+
(re.compile(r'\buvicorn\.run\s*\('), "uvicorn_run"),
|
|
212
|
+
(re.compile(r'\bweb\.run_app\s*\('), "aiohttp_run_app"),
|
|
213
|
+
(re.compile(r'\bapp\.run\s*\('), "wsgi_app_run"), # Flask, Sanic, AIOHTTP variants
|
|
214
|
+
(re.compile(r'\btornado\.ioloop\.IOLoop\.current\(\)\.start\s*\('), "tornado_ioloop_start"),
|
|
215
|
+
(re.compile(r'\bif\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:'), "main_guard"),
|
|
216
|
+
(re.compile(r'\bSparkSession\.builder\b'), "spark_session"), # data plane
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Secrets — additive over built-in (which covers os.getenv).
|
|
221
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
222
|
+
re.compile(
|
|
223
|
+
r'\bos\.environ\.get\s*\(\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]',
|
|
224
|
+
),
|
|
225
|
+
re.compile(
|
|
226
|
+
r'\bos\.environ\s*\[\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]\s*\]',
|
|
227
|
+
),
|
|
228
|
+
re.compile(
|
|
229
|
+
r'\bdotenv_values\s*\(.*?\)\s*\.get\s*\(\s*[\'"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)[\'"]',
|
|
230
|
+
re.DOTALL,
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _line_of(content: str, offset: int) -> int:
|
|
236
|
+
if offset <= 0:
|
|
237
|
+
return 1
|
|
238
|
+
return content.count("\n", 0, offset) + 1
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
242
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
243
|
+
line_end = content.find("\n", offset)
|
|
244
|
+
if line_end == -1:
|
|
245
|
+
line_end = len(content)
|
|
246
|
+
line = content[line_start:line_end].strip()
|
|
247
|
+
if len(line) > max_chars:
|
|
248
|
+
line = line[: max_chars - 1] + "…"
|
|
249
|
+
return line
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _name_from_pyproject(pyproject_path: Path) -> str | None:
|
|
253
|
+
if not pyproject_path.exists():
|
|
254
|
+
return None
|
|
255
|
+
try:
|
|
256
|
+
text = pyproject_path.read_text(encoding="utf-8")
|
|
257
|
+
except UnicodeDecodeError:
|
|
258
|
+
return None
|
|
259
|
+
# Try [project] name = "..." (PEP 621)
|
|
260
|
+
match = re.search(r'\[project\][^\[]*?name\s*=\s*["\']([^"\']+)["\']', text, re.DOTALL)
|
|
261
|
+
if match:
|
|
262
|
+
return match.group(1).strip()
|
|
263
|
+
# Fall back to [tool.poetry] name = "..."
|
|
264
|
+
match = re.search(r'\[tool\.poetry\][^\[]*?name\s*=\s*["\']([^"\']+)["\']', text, re.DOTALL)
|
|
265
|
+
if match:
|
|
266
|
+
return match.group(1).strip()
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _name_from_setup_py(setup_path: Path) -> str | None:
|
|
271
|
+
if not setup_path.exists():
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
text = setup_path.read_text(encoding="utf-8")
|
|
275
|
+
except UnicodeDecodeError:
|
|
276
|
+
return None
|
|
277
|
+
match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', text)
|
|
278
|
+
if match:
|
|
279
|
+
return match.group(1).strip()
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _name_from_setup_cfg(setup_cfg_path: Path) -> str | None:
|
|
284
|
+
if not setup_cfg_path.exists():
|
|
285
|
+
return None
|
|
286
|
+
try:
|
|
287
|
+
text = setup_cfg_path.read_text(encoding="utf-8")
|
|
288
|
+
except UnicodeDecodeError:
|
|
289
|
+
return None
|
|
290
|
+
match = re.search(r'\[metadata\][^\[]*?name\s*=\s*([^\s\n]+)', text, re.DOTALL)
|
|
291
|
+
if match:
|
|
292
|
+
return match.group(1).strip()
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_django_settings(content: str) -> bool:
|
|
297
|
+
return bool(re.search(r'\b(INSTALLED_APPS|DATABASES|MIDDLEWARE|ROOT_URLCONF)\s*=', content))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_django_urls(content: str) -> bool:
|
|
301
|
+
return bool(re.search(r'\burlpatterns\s*=', content))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _has_litestar_or_starlite_import(content: str) -> bool:
|
|
305
|
+
return "litestar" in content or "starlite" in content
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _has_aiohttp_import(content: str) -> bool:
|
|
309
|
+
return "aiohttp" in content or re.search(r'\bweb\.Application\s*\(', content) is not None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _has_starlette_import(content: str) -> bool:
|
|
313
|
+
return "starlette" in content
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _has_sanic_import(content: str) -> bool:
|
|
317
|
+
return "sanic" in content
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _has_django_import(content: str) -> bool:
|
|
321
|
+
return "django" in content or _is_django_urls(content) or _is_django_settings(content)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _has_drf_import(content: str) -> bool:
|
|
325
|
+
return "rest_framework" in content
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class PythonAnalyzer:
|
|
329
|
+
metadata = AnalyzerMetadata(
|
|
330
|
+
name="python",
|
|
331
|
+
display_name="Python Comprehensive Analyzer",
|
|
332
|
+
version="0.1.0",
|
|
333
|
+
description="Comprehensive Python analyzer for Django, Starlette, AIOHTTP, Sanic, Litestar, DRF; SQLAlchemy/asyncpg/motor; passlib/PyJWT/authlib; httpx/aiohttp.",
|
|
334
|
+
scope="Python projects (pyproject.toml, setup.py, manage.py, or any *.py tree). Additive over the built-in python-web analyzer.",
|
|
335
|
+
targets=["python", "django", "starlette", "aiohttp", "sanic", "litestar"],
|
|
336
|
+
languages=["python"],
|
|
337
|
+
priority=15, # Slightly higher priority than the built-in (20) — runs first; dedup handles overlap.
|
|
338
|
+
experimental=False,
|
|
339
|
+
enabled_by_default=True,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def name(self) -> str:
|
|
344
|
+
return self.metadata.name
|
|
345
|
+
|
|
346
|
+
# ---------- Public entry points ----------
|
|
347
|
+
|
|
348
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
349
|
+
root = Path(repo_path).resolve()
|
|
350
|
+
if not root.exists() or not root.is_dir():
|
|
351
|
+
return False
|
|
352
|
+
for marker in ("pyproject.toml", "setup.py", "setup.cfg", "manage.py", "Pipfile"):
|
|
353
|
+
if (root / marker).exists():
|
|
354
|
+
return True
|
|
355
|
+
for path in root.rglob("*.py"):
|
|
356
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
357
|
+
continue
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
362
|
+
root = Path(repo_path).resolve()
|
|
363
|
+
result = ScanResult(root=str(root))
|
|
364
|
+
if not root.exists() or not root.is_dir():
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
# Service-name hints from project metadata
|
|
368
|
+
for name_provider, marker in (
|
|
369
|
+
(_name_from_pyproject, "pyproject.toml"),
|
|
370
|
+
(_name_from_setup_py, "setup.py"),
|
|
371
|
+
(_name_from_setup_cfg, "setup.cfg"),
|
|
372
|
+
):
|
|
373
|
+
project_name = name_provider(root / marker)
|
|
374
|
+
if project_name:
|
|
375
|
+
self._append_unique_service(result, f"package:{project_name}", marker)
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
if (root / "manage.py").exists():
|
|
379
|
+
self._append_unique_service(result, "framework:django", "manage.py")
|
|
380
|
+
|
|
381
|
+
for file_path in root.rglob("*.py"):
|
|
382
|
+
if not file_path.is_file():
|
|
383
|
+
continue
|
|
384
|
+
if any(part in SKIP_DIRS for part in file_path.parts):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
result.files_scanned += 1
|
|
388
|
+
if "python" not in result.languages:
|
|
389
|
+
result.languages.append("python")
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
content = file_path.read_text(encoding="utf-8")
|
|
393
|
+
except UnicodeDecodeError:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
relative = str(file_path.relative_to(root))
|
|
397
|
+
self._extract_routes(content, relative, result)
|
|
398
|
+
self._extract_databases(content, relative, result)
|
|
399
|
+
self._extract_django_settings(content, relative, result)
|
|
400
|
+
self._extract_auth(content, relative, result)
|
|
401
|
+
self._extract_secrets(content, relative, result)
|
|
402
|
+
self._extract_external_calls(content, relative, result)
|
|
403
|
+
self._extract_frameworks(content, relative, result)
|
|
404
|
+
self._extract_entrypoints(content, relative, result)
|
|
405
|
+
|
|
406
|
+
result.languages.sort()
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
# ---------- Extractors ----------
|
|
410
|
+
|
|
411
|
+
def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
|
|
412
|
+
# Django routing only fires inside files that look like urls.py
|
|
413
|
+
if _has_django_import(content) and _is_django_urls(content):
|
|
414
|
+
for match in DJANGO_PATH_PATTERN.finditer(content):
|
|
415
|
+
path = "/" + match.group(1).lstrip("/")
|
|
416
|
+
self._append_unique_route(result, path, "ANY", relative, _line_of(content, match.start()))
|
|
417
|
+
for match in DJANGO_RE_PATH_PATTERN.finditer(content):
|
|
418
|
+
path = match.group(1)
|
|
419
|
+
self._append_unique_route(result, path, "ANY", relative, _line_of(content, match.start()))
|
|
420
|
+
for match in DJANGO_LEGACY_URL_PATTERN.finditer(content):
|
|
421
|
+
path = match.group(1)
|
|
422
|
+
self._append_unique_route(result, path, "ANY", relative, _line_of(content, match.start()))
|
|
423
|
+
|
|
424
|
+
# Django REST Framework routers
|
|
425
|
+
if _has_drf_import(content):
|
|
426
|
+
for match in DRF_ROUTER_PATTERN.finditer(content):
|
|
427
|
+
path = "/" + match.group(1).lstrip("/")
|
|
428
|
+
self._append_unique_route(result, path, "ANY", relative, _line_of(content, match.start()))
|
|
429
|
+
for match in DJANGO_API_VIEW_PATTERN.finditer(content):
|
|
430
|
+
methods = re.findall(r'[\'"]([A-Z]+)[\'"]', match.group(1))
|
|
431
|
+
# No path on @api_view alone — caller must pair with path() in urls.py.
|
|
432
|
+
# We emit the methods as a low-signal hint via framework_hint to capture intent.
|
|
433
|
+
line = _line_of(content, match.start())
|
|
434
|
+
for method in methods:
|
|
435
|
+
self._append_unique_framework(
|
|
436
|
+
result, f"drf_api_view:{method}", relative, line,
|
|
437
|
+
_line_snippet(content, match.start()),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Starlette
|
|
441
|
+
if _has_starlette_import(content):
|
|
442
|
+
for match in STARLETTE_ROUTE_PATTERN.finditer(content):
|
|
443
|
+
path = match.group(1)
|
|
444
|
+
rest = match.group("rest") or ""
|
|
445
|
+
methods_match = re.search(r'methods\s*=\s*\[([^\]]+)\]', rest)
|
|
446
|
+
line = _line_of(content, match.start())
|
|
447
|
+
if methods_match:
|
|
448
|
+
methods = [m.upper() for m in re.findall(r'[\'"]([A-Z]+)[\'"]', methods_match.group(1))]
|
|
449
|
+
else:
|
|
450
|
+
methods = ["ANY"]
|
|
451
|
+
for method in methods:
|
|
452
|
+
self._append_unique_route(result, path, method, relative, line)
|
|
453
|
+
for match in STARLETTE_WS_PATTERN.finditer(content):
|
|
454
|
+
path = match.group(1)
|
|
455
|
+
self._append_unique_route(result, path, "WS", relative, _line_of(content, match.start()))
|
|
456
|
+
|
|
457
|
+
# AIOHTTP — gated by import presence so generic `add_get`/`web.get` doesn't fire.
|
|
458
|
+
if _has_aiohttp_import(content):
|
|
459
|
+
for match in AIOHTTP_ADD_PATTERN.finditer(content):
|
|
460
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
461
|
+
self._append_unique_route(result, path, method, relative, _line_of(content, match.start()))
|
|
462
|
+
for match in AIOHTTP_WEB_HELPER_PATTERN.finditer(content):
|
|
463
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
464
|
+
self._append_unique_route(result, path, method, relative, _line_of(content, match.start()))
|
|
465
|
+
|
|
466
|
+
# Sanic — gated by sanic import
|
|
467
|
+
if _has_sanic_import(content):
|
|
468
|
+
for match in SANIC_DECORATOR_PATTERN.finditer(content):
|
|
469
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
470
|
+
self._append_unique_route(result, path, method, relative, _line_of(content, match.start()))
|
|
471
|
+
|
|
472
|
+
# Litestar / Starlite — gated to avoid false positives on generic `@get(...)` decorators.
|
|
473
|
+
if _has_litestar_or_starlite_import(content):
|
|
474
|
+
for match in LITESTAR_DECORATOR_PATTERN.finditer(content):
|
|
475
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
476
|
+
self._append_unique_route(result, path, method, relative, _line_of(content, match.start()))
|
|
477
|
+
|
|
478
|
+
# Flask add_url_rule (additive — built-in covers @app.route)
|
|
479
|
+
if "flask" in content.lower() or "Blueprint(" in content:
|
|
480
|
+
for match in FLASK_ADD_URL_RULE_PATTERN.finditer(content):
|
|
481
|
+
path = match.group(1)
|
|
482
|
+
self._append_unique_route(result, path, "ANY", relative, _line_of(content, match.start()))
|
|
483
|
+
|
|
484
|
+
def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
|
|
485
|
+
for pattern, kind in DB_PATTERNS:
|
|
486
|
+
match = pattern.search(content)
|
|
487
|
+
if match is None:
|
|
488
|
+
continue
|
|
489
|
+
self._append_unique_database(
|
|
490
|
+
result, kind, relative,
|
|
491
|
+
_line_of(content, match.start()),
|
|
492
|
+
_line_snippet(content, match.start()),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# SQLAlchemy with dialect inference
|
|
496
|
+
for match in SQLALCHEMY_ENGINE_PATTERN.finditer(content):
|
|
497
|
+
dialect = match.group(1).lower()
|
|
498
|
+
kind = {
|
|
499
|
+
"postgresql": "postgresql",
|
|
500
|
+
"postgres": "postgresql",
|
|
501
|
+
"mysql": "mysql",
|
|
502
|
+
"mariadb": "mariadb",
|
|
503
|
+
"sqlite": "sqlite",
|
|
504
|
+
"oracle": "oracle",
|
|
505
|
+
"mssql": "sqlserver",
|
|
506
|
+
}.get(dialect, "sql")
|
|
507
|
+
self._append_unique_database(
|
|
508
|
+
result, kind, relative,
|
|
509
|
+
_line_of(content, match.start()),
|
|
510
|
+
_line_snippet(content, match.start()),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def _extract_django_settings(self, content: str, relative: str, result: ScanResult) -> None:
|
|
514
|
+
if not _is_django_settings(content):
|
|
515
|
+
return
|
|
516
|
+
# DATABASES dict — extract ENGINE values
|
|
517
|
+
for match in DJANGO_ENGINE_PATTERN.finditer(content):
|
|
518
|
+
engine_short = match.group(1).lower()
|
|
519
|
+
kind = {
|
|
520
|
+
"postgresql": "postgresql",
|
|
521
|
+
"postgresql_psycopg2": "postgresql",
|
|
522
|
+
"mysql": "mysql",
|
|
523
|
+
"sqlite3": "sqlite",
|
|
524
|
+
"oracle": "oracle",
|
|
525
|
+
}.get(engine_short, "sql")
|
|
526
|
+
self._append_unique_database(
|
|
527
|
+
result, kind, relative,
|
|
528
|
+
_line_of(content, match.start()),
|
|
529
|
+
_line_snippet(content, match.start()),
|
|
530
|
+
)
|
|
531
|
+
# INSTALLED_APPS — extract third-party / local apps as service hints
|
|
532
|
+
installed_match = re.search(r'INSTALLED_APPS\s*=\s*\[(?P<body>.*?)\]', content, re.DOTALL)
|
|
533
|
+
if installed_match:
|
|
534
|
+
for app_match in re.finditer(r'[\'"]([a-zA-Z][\w.]*)[\'"]', installed_match.group("body")):
|
|
535
|
+
app_name = app_match.group(1)
|
|
536
|
+
# Skip Django built-ins to keep signal high.
|
|
537
|
+
if app_name.startswith("django.") or app_name in {"django"}:
|
|
538
|
+
continue
|
|
539
|
+
self._append_unique_service(result, f"django_app:{app_name}", relative)
|
|
540
|
+
|
|
541
|
+
def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
|
|
542
|
+
for pattern, hint, confidence in AUTH_PATTERNS:
|
|
543
|
+
match = pattern.search(content)
|
|
544
|
+
if match is None:
|
|
545
|
+
continue
|
|
546
|
+
self._append_unique_auth(
|
|
547
|
+
result, hint, relative,
|
|
548
|
+
_line_of(content, match.start()),
|
|
549
|
+
_line_snippet(content, match.start()),
|
|
550
|
+
confidence,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
554
|
+
for pattern in SECRET_PATTERNS:
|
|
555
|
+
for match in pattern.finditer(content):
|
|
556
|
+
name = match.group(1)
|
|
557
|
+
self._append_unique_secret(
|
|
558
|
+
result, name, relative,
|
|
559
|
+
_line_of(content, match.start()),
|
|
560
|
+
_line_snippet(content, match.start()),
|
|
561
|
+
)
|
|
562
|
+
# pydantic-settings BaseSettings: class Settings(BaseSettings): jwt_secret: str = ...
|
|
563
|
+
if "BaseSettings" in content:
|
|
564
|
+
for match in re.finditer(
|
|
565
|
+
r'^\s+([a-zA-Z_][a-zA-Z0-9_]*(?:secret|token|key|password|pass|pwd)[a-zA-Z0-9_]*)\s*:\s*\w',
|
|
566
|
+
content,
|
|
567
|
+
re.MULTILINE | re.IGNORECASE,
|
|
568
|
+
):
|
|
569
|
+
name = match.group(1)
|
|
570
|
+
self._append_unique_secret(
|
|
571
|
+
result, name, relative,
|
|
572
|
+
_line_of(content, match.start()),
|
|
573
|
+
_line_snippet(content, match.start()),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
577
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
578
|
+
for match in pattern.finditer(content):
|
|
579
|
+
target = match.group(1)
|
|
580
|
+
if not (target.startswith("http://") or target.startswith("https://")):
|
|
581
|
+
continue
|
|
582
|
+
self._append_unique_external(
|
|
583
|
+
result, target, relative,
|
|
584
|
+
_line_of(content, match.start()),
|
|
585
|
+
_line_snippet(content, match.start()),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
|
|
589
|
+
for pattern, name in FRAMEWORK_PATTERNS:
|
|
590
|
+
match = pattern.search(content)
|
|
591
|
+
if match is None:
|
|
592
|
+
continue
|
|
593
|
+
self._append_unique_framework(
|
|
594
|
+
result, name, relative,
|
|
595
|
+
_line_of(content, match.start()),
|
|
596
|
+
_line_snippet(content, match.start()),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
600
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
601
|
+
match = pattern.search(content)
|
|
602
|
+
if match is None:
|
|
603
|
+
continue
|
|
604
|
+
self._append_unique_entrypoint(
|
|
605
|
+
result, hint, relative,
|
|
606
|
+
_line_of(content, match.start()),
|
|
607
|
+
_line_snippet(content, match.start()),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# ---------- Append helpers ----------
|
|
611
|
+
|
|
612
|
+
@staticmethod
|
|
613
|
+
def _append_unique_route(result: ScanResult, path: str, method: str, file: str, line: int | None) -> None:
|
|
614
|
+
key = (path, method, file)
|
|
615
|
+
if any((item.path, item.method, item.file) == key for item in result.routes):
|
|
616
|
+
return
|
|
617
|
+
result.routes.append(Route(path=path, method=method, file=file, line=line))
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
621
|
+
key = (kind, file)
|
|
622
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
623
|
+
return
|
|
624
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
628
|
+
key = (hint, file)
|
|
629
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
630
|
+
return
|
|
631
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
635
|
+
key = (name, file)
|
|
636
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
637
|
+
return
|
|
638
|
+
result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
642
|
+
key = (target, file)
|
|
643
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
644
|
+
return
|
|
645
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
646
|
+
|
|
647
|
+
@staticmethod
|
|
648
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
649
|
+
key = (hint, file)
|
|
650
|
+
if any((item.hint, item.file) == key for item in result.framework_hints):
|
|
651
|
+
return
|
|
652
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
656
|
+
key = (hint, file)
|
|
657
|
+
if any((item.hint, item.file) == key for item in result.entrypoint_hints):
|
|
658
|
+
return
|
|
659
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
660
|
+
|
|
661
|
+
@staticmethod
|
|
662
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
663
|
+
key = (hint, file)
|
|
664
|
+
if any((item.hint, item.file) == key for item in result.service_hints):
|
|
665
|
+
return
|
|
666
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
__all__ = ["PythonAnalyzer"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from attackmap.sdk.contracts import AnalyzerMetadata, AnalyzerProtocol
|
|
4
|
+
from attackmap.sdk.models import (
|
|
5
|
+
AuthHint,
|
|
6
|
+
DatabaseHint,
|
|
7
|
+
EntrypointHint,
|
|
8
|
+
ExternalCall,
|
|
9
|
+
FrameworkHint,
|
|
10
|
+
Route,
|
|
11
|
+
ScanResult,
|
|
12
|
+
SecretHint,
|
|
13
|
+
ServiceHint,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
AttackMapAnalyzerProtocol = AnalyzerProtocol
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AnalyzerMetadata",
|
|
20
|
+
"AttackMapAnalyzerProtocol",
|
|
21
|
+
"Route",
|
|
22
|
+
"ExternalCall",
|
|
23
|
+
"DatabaseHint",
|
|
24
|
+
"AuthHint",
|
|
25
|
+
"EntrypointHint",
|
|
26
|
+
"FrameworkHint",
|
|
27
|
+
"ServiceHint",
|
|
28
|
+
"SecretHint",
|
|
29
|
+
"ScanResult",
|
|
30
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attackmap-analyzer-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Comprehensive Python ecosystem analyzer plugin for AttackMap (Django, Starlette, AIOHTTP, Sanic, Litestar, DRF; SQLAlchemy/asyncpg/motor; passlib/PyJWT/authlib; httpx/aiohttp).
|
|
5
|
+
Author: AttackMap Contributors
|
|
6
|
+
Author-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
7
|
+
Maintainer-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-python
|
|
10
|
+
Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-python
|
|
11
|
+
Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-python/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-python/blob/main/CHANGELOG.md
|
|
13
|
+
Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
|
|
14
|
+
Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Intended Audience :: System Administrators
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Requires-Dist: pydantic>=2.7.0
|
|
33
|
+
Provides-Extra: core
|
|
34
|
+
Requires-Dist: attackmap>=0.1.0; extra == "core"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
38
|
+
Requires-Dist: attackmap>=0.1.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# attackmap-analyzer-python
|
|
42
|
+
|
|
43
|
+
Comprehensive Python ecosystem analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
|
|
44
|
+
|
|
45
|
+
This plugin is **additive** over AttackMap's built-in `python-web` analyzer. The built-in handles FastAPI / Flask routes via the core scanner; this plugin adds everything else.
|
|
46
|
+
|
|
47
|
+
- **Web frameworks** —
|
|
48
|
+
- **Django** routing (`path("users/", ...)`, `re_path(r"^api/...$", ...)`, legacy `url(...)`) gated by `urlpatterns =` in the file
|
|
49
|
+
- **Django REST Framework** routers (`router.register(r'users', UserViewSet)`) and `@api_view(['GET', 'POST'])` decorators
|
|
50
|
+
- **Starlette** `Route("/x", endpoint=h, methods=["GET", "POST"])` and `WebSocketRoute("/ws/...")`
|
|
51
|
+
- **AIOHTTP** `app.router.add_get/post/...` and `web.get/post/...` helpers (gated by aiohttp import)
|
|
52
|
+
- **Sanic** `@app.get("/x")`, `@app.post("/x")` (gated by sanic import)
|
|
53
|
+
- **Litestar** (and legacy Starlite) top-level `@get("/x")` / `@post(...)` decorators (gated by litestar/starlite import)
|
|
54
|
+
- **Flask** `app.add_url_rule("/x", view_func=...)` (additive — built-in covers `@app.route`)
|
|
55
|
+
- **Databases** —
|
|
56
|
+
- **SQLAlchemy** `create_engine("postgresql://...")` with dialect-aware kind inference (postgresql / mysql / mariadb / sqlite / oracle / sqlserver)
|
|
57
|
+
- **asyncpg** / **psycopg2-3** / **pymysql** / **motor.motor_asyncio** / **pymongo** / **redis.asyncio** / **aioredis** / **sqlmodel** / **tortoise**
|
|
58
|
+
- **boto3 resources/clients** for `dynamodb` and `s3`
|
|
59
|
+
- **Django** `DATABASES` dict in `settings.py` — `ENGINE` value parsed for the kind
|
|
60
|
+
- **Auth packages** —
|
|
61
|
+
- **passlib** `CryptContext`, `passlib.hash.argon2/bcrypt/scrypt`
|
|
62
|
+
- **bcrypt** standalone (`bcrypt.hashpw`, `bcrypt.checkpw`)
|
|
63
|
+
- **argon2-cffi** (`from argon2 import PasswordHasher`)
|
|
64
|
+
- **PyJWT** / **python-jose** (`jwt.encode`, `jwt.decode`, `jose.jwt`)
|
|
65
|
+
- **authlib** (`OAuth(...)`, `OAuth2Session(...)`)
|
|
66
|
+
- **fastapi-users** (`BearerTransport`, `JWTStrategy`)
|
|
67
|
+
- **flask-jwt-extended** (`@jwt_required`, `create_access_token`)
|
|
68
|
+
- **django.contrib.auth** (`authenticate`, `make_password`)
|
|
69
|
+
- **casbin** (`Enforcer`)
|
|
70
|
+
- **HTTP clients (external calls)** — **httpx** sync + async, **aiohttp.ClientSession**, **urllib.request.urlopen**
|
|
71
|
+
- **Secrets** —
|
|
72
|
+
- `os.environ.get("X")` and `os.environ["X"]` with secret-shaped names (built-in only covers `os.getenv`)
|
|
73
|
+
- **pydantic-settings** `BaseSettings` field-name extraction (`jwt_secret: str = ...`)
|
|
74
|
+
- `dotenv_values(...)` accessors with secret-shaped keys
|
|
75
|
+
- **Service hints** — `[project] name` from `pyproject.toml`, `[tool.poetry] name`, `name="..."` from `setup.py`, `[metadata] name` from `setup.cfg`, presence of `manage.py` → `framework:django`, Django `INSTALLED_APPS` third-party / local apps
|
|
76
|
+
|
|
77
|
+
All emissions populate AttackMap's Signal v2 fields (line numbers, evidence snippets, confidence) so downstream insights can cite `path/to/module.py:NN`.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install git+https://github.com/mlaify/attackmap-analyzer-python.git
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The analyzer is auto-discovered by AttackMap via the `attackmap.analyzers` entry-point group.
|
|
86
|
+
|
|
87
|
+
## Usage with AttackMap
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Auto-discovered when installed:
|
|
91
|
+
attackmap analyze /path/to/python/repo
|
|
92
|
+
|
|
93
|
+
# Or invoke explicitly:
|
|
94
|
+
attackmap analyze /path/to/python/repo --module python
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Detection
|
|
98
|
+
|
|
99
|
+
`detect()` returns true when any of the following are present, ignoring `.venv/`, `venv/`, `env/`, `.tox/`, `__pycache__/`, `.pytest_cache/`, `dist/`, `build/`, `node_modules/`, `.git/`, `.mypy_cache/`, `.ruff_cache/`, and `site-packages/`:
|
|
100
|
+
|
|
101
|
+
- A `pyproject.toml`, `setup.py`, `setup.cfg`, `manage.py`, or `Pipfile` at the root
|
|
102
|
+
- Any `.py` file in the tree
|
|
103
|
+
|
|
104
|
+
## Coverage notes
|
|
105
|
+
|
|
106
|
+
- **Framework-gated extraction**: route extractors for AIOHTTP, Sanic, Litestar, and Starlette only fire when the file imports the corresponding framework. This prevents mis-attributing generic `@get(...)` / `web.get(...)` / `app.get(...)` calls to a framework when they're really custom decorators or method calls.
|
|
107
|
+
- **Django routing requires `urlpatterns =`** to be in the file. A bare `path("/x", ...)` in any other context (e.g., `pathlib.Path("/x")` is well-behaved, but a project that also has `from django.urls import path; path("/x", view)` outside a urls.py won't fire — by design).
|
|
108
|
+
- **Django settings extraction**: gated by presence of one of `INSTALLED_APPS`, `DATABASES`, `MIDDLEWARE`, or `ROOT_URLCONF`. Built-in Django apps (`django.*`) are filtered out of the INSTALLED_APPS service hints to keep signal high.
|
|
109
|
+
- **DRF `@api_view(['GET', 'POST'])`**: emits framework hints (`drf_api_view:GET`, `drf_api_view:POST`) rather than routes — the actual path is in `urls.py`, which is extracted separately.
|
|
110
|
+
- **Tornado** is not yet covered — its class-based `RequestHandler` routing requires per-class method extraction (`def get(self): ...`, `def post(self): ...`).
|
|
111
|
+
- **pydantic-settings** field extraction matches any class field whose name contains `secret`, `token`, `key`, `password`, `pass`, or `pwd`. False positives are possible (e.g., a non-secret `signing_key` config). Confidence is the standard 0.85.
|
|
112
|
+
|
|
113
|
+
## Coexistence with the built-in `python-web` analyzer
|
|
114
|
+
|
|
115
|
+
AttackMap ships with a `python-web` built-in that handles FastAPI / Flask via the core scanner. When this plugin is installed, both analyzers run on every Python repo. AttackMap's overlay deduplication merges their output so users see a single unified set of findings — no double-counting.
|
|
116
|
+
|
|
117
|
+
This plugin runs at `priority=15` (slightly higher than the built-in's 20), so its richer output lands first.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
attackmap_analyzer_python/__init__.py,sha256=sDkhPmEkGNZOqEPM-UJqpTaH8a04aTHGtwF5-Qw-sEU,67
|
|
2
|
+
attackmap_analyzer_python/analyzer.py,sha256=uG97POJZZFI2XN-IArikr8W1BLrXsbDOs2gy7_6uQuw,28957
|
|
3
|
+
attackmap_analyzer_python/contracts.py,sha256=dsveFe7szz7xceev0PIfyc5jQlNiES0whsdxwu9D7YI,578
|
|
4
|
+
attackmap_analyzer_python-0.1.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
|
|
5
|
+
attackmap_analyzer_python-0.1.0.dist-info/METADATA,sha256=YAKBNz-MtaCYa4Sqa1PebLMaY-IrCTAQk8h0VMfLPgI,7510
|
|
6
|
+
attackmap_analyzer_python-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
attackmap_analyzer_python-0.1.0.dist-info/entry_points.txt,sha256=_5AKUyJJuZslwL4pj-YwxB1QHw6__V-Nc02x0u-2ffc,72
|
|
8
|
+
attackmap_analyzer_python-0.1.0.dist-info/top_level.txt,sha256=ETsHPjZJDHv7lRzckg-JQrnhRo3lrsXtR2AQ0AM7mho,26
|
|
9
|
+
attackmap_analyzer_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Davis and AttackMap Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attackmap_analyzer_python
|