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.

@@ -0,0 +1,3 @@
1
+ from .analyzer import PythonAnalyzer
2
+
3
+ __all__ = ["PythonAnalyzer"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ python = attackmap_analyzer_python:PythonAnalyzer
@@ -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