attackmap 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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +278 -0
- attackmap/analyzer_contracts.py +104 -0
- attackmap/analyzers.py +561 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +140 -0
- attackmap/cli.py +185 -0
- attackmap/context_pack.py +82 -0
- attackmap/control_model.py +272 -0
- attackmap/defensive_review.py +597 -0
- attackmap/detection_opportunities.py +343 -0
- attackmap/graph.py +26 -0
- attackmap/insights.py +702 -0
- attackmap/llm_review.py +346 -0
- attackmap/models.py +352 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +121 -0
- attackmap/report.py +74 -0
- attackmap/review_eval.py +281 -0
- attackmap/review_json.py +300 -0
- attackmap/review_prompts.py +230 -0
- attackmap/scanner.py +446 -0
- attackmap/sdk/__init__.py +41 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/models.py +29 -0
- attackmap/security_overlay.py +69 -0
- attackmap/threat_model.py +919 -0
- attackmap-0.1.0.dist-info/METADATA +257 -0
- attackmap-0.1.0.dist-info/RECORD +33 -0
- attackmap-0.1.0.dist-info/WHEEL +5 -0
- attackmap-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap-0.1.0.dist-info/top_level.txt +1 -0
attackmap/scanner.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .sdk.models import AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
|
|
7
|
+
|
|
8
|
+
# Scanner responsibilities are intentionally generic-only:
|
|
9
|
+
# - file walking and suffix filtering
|
|
10
|
+
# - route extraction
|
|
11
|
+
# - external-call extraction
|
|
12
|
+
# - datastore/auth/secret hint extraction
|
|
13
|
+
# Ecosystem overlays (for example node-service and atproto service/protocol hints)
|
|
14
|
+
# must be emitted by specialized analyzers, not by this module.
|
|
15
|
+
|
|
16
|
+
CODE_EXTENSIONS = {
|
|
17
|
+
".py": "python",
|
|
18
|
+
".js": "javascript",
|
|
19
|
+
".ts": "typescript",
|
|
20
|
+
".tsx": "typescript",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
FASTAPI_ROUTER_PATTERN = re.compile(
|
|
24
|
+
r"(\w+)\s*=\s*APIRouter\(\s*(?:[^)]*?\bprefix\s*=\s*['\"]([^'\"]*)['\"])?",
|
|
25
|
+
re.IGNORECASE | re.DOTALL,
|
|
26
|
+
)
|
|
27
|
+
FASTAPI_INCLUDE_ROUTER_PATTERN = re.compile(
|
|
28
|
+
r"(\w+)\.include_router\(\s*(\w+)(?:\s*,\s*prefix\s*=\s*['\"]([^'\"]*)['\"])?",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
FASTAPI_DECORATOR_PATTERN = re.compile(
|
|
32
|
+
r"@(\w+)\.(get|post|put|delete|patch|options|head)\(\s*['\"]([^'\"]+)['\"]",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
FASTAPI_API_ROUTE_PATTERN = re.compile(
|
|
36
|
+
r"@(\w+)\.api_route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
|
|
37
|
+
re.IGNORECASE | re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
FLASK_BLUEPRINT_PATTERN = re.compile(
|
|
40
|
+
r"(\w+)\s*=\s*Blueprint\(\s*['\"][^'\"]+['\"]\s*,\s*[^,]+(?:,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
|
|
41
|
+
re.IGNORECASE | re.DOTALL,
|
|
42
|
+
)
|
|
43
|
+
FLASK_REGISTER_BLUEPRINT_PATTERN = re.compile(
|
|
44
|
+
r"(\w+)\.register_blueprint\(\s*(\w+)(?:\s*,\s*url_prefix\s*=\s*['\"]([^'\"]*)['\"])?",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
FLASK_ROUTE_PATTERN = re.compile(
|
|
48
|
+
r"@(\w+)\.route\(\s*['\"]([^'\"]+)['\"](?P<args>.*?)\)",
|
|
49
|
+
re.IGNORECASE | re.DOTALL,
|
|
50
|
+
)
|
|
51
|
+
EXPRESS_DIRECT_ROUTE_PATTERN = re.compile(
|
|
52
|
+
r"\b(\w+)\.(get|post|put|delete|patch|options|head|all)\(\s*['\"]([^'\"]+)['\"]",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
EXPRESS_CHAIN_ROUTE_PATTERN = re.compile(
|
|
56
|
+
r"\b(\w+)\.route\(\s*['\"]([^'\"]+)['\"]\s*\)(?P<chain>[\s\S]*?)(?=(?:\n\s*\w+\.)|\Z)",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
EXPRESS_USE_PATTERN = re.compile(
|
|
60
|
+
r"\b(\w+)\.use\(\s*['\"]([^'\"]+)['\"]\s*,\s*(\w+)\s*\)",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
METHOD_LIST_PATTERN = re.compile(r"['\"](GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|ALL)['\"]", re.IGNORECASE)
|
|
64
|
+
|
|
65
|
+
EXTERNAL_CALL_PATTERNS = [
|
|
66
|
+
re.compile(r"requests\.(get|post|put|delete|patch)\(['\"]([^'\"]+)['\"]"),
|
|
67
|
+
re.compile(r"axios\.(get|post|put|delete|patch)\(['\"]([^'\"]+)['\"]"),
|
|
68
|
+
re.compile(r"fetch\(['\"]([^'\"]+)['\"]"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
DB_KEYWORDS = {
|
|
72
|
+
"postgres": "postgresql",
|
|
73
|
+
"psycopg": "postgresql",
|
|
74
|
+
"sqlalchemy": "sql",
|
|
75
|
+
"mongodb": "mongodb",
|
|
76
|
+
"mongo": "mongodb",
|
|
77
|
+
"redis": "redis",
|
|
78
|
+
"sqlite": "sqlite",
|
|
79
|
+
"mysql": "mysql",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
DB_PATTERNS = [
|
|
83
|
+
(re.compile(r"create_engine\(\s*['\"](?:postgresql|mysql|sqlite|mssql|oracle)\+?", re.IGNORECASE), "sql"),
|
|
84
|
+
(re.compile(r"sqlite3\.connect\(", re.IGNORECASE), "sqlite"),
|
|
85
|
+
(re.compile(r"psycopg(?:2)?\.connect\(", re.IGNORECASE), "postgresql"),
|
|
86
|
+
(re.compile(r"(?:AsyncIOMotorClient|MongoClient)\(", re.IGNORECASE), "mongodb"),
|
|
87
|
+
(re.compile(r"redis\.(?:Redis|StrictRedis)\(", re.IGNORECASE), "redis"),
|
|
88
|
+
(re.compile(r"new\s+PrismaClient\(", re.IGNORECASE), "sql"),
|
|
89
|
+
(re.compile(r"mongoose\.connect\(", re.IGNORECASE), "mongodb"),
|
|
90
|
+
(re.compile(r"new\s+Pool\(", re.IGNORECASE), "postgresql"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
AUTH_KEYWORDS = [
|
|
94
|
+
"jwt",
|
|
95
|
+
"oauth",
|
|
96
|
+
"auth0",
|
|
97
|
+
"apikey",
|
|
98
|
+
"api_key",
|
|
99
|
+
"bearer",
|
|
100
|
+
"session",
|
|
101
|
+
"password",
|
|
102
|
+
"token",
|
|
103
|
+
"mfa",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
AUTH_PATTERNS = [
|
|
107
|
+
(re.compile(r"@login_required\b", re.IGNORECASE), "login_required"),
|
|
108
|
+
(re.compile(r"@jwt_required\b", re.IGNORECASE), "jwt"),
|
|
109
|
+
(re.compile(r"Depends\(\s*(?:oauth2_scheme|get_current_user|current_user|verify_token)\s*\)", re.IGNORECASE), "depends_auth"),
|
|
110
|
+
(re.compile(r"OAuth2PasswordBearer\(", re.IGNORECASE), "oauth"),
|
|
111
|
+
(re.compile(r"request\.authorization\b", re.IGNORECASE), "authorization"),
|
|
112
|
+
(re.compile(r"Authorization['\"]?\s*\]", re.IGNORECASE), "authorization"),
|
|
113
|
+
(re.compile(r"passport\.authenticate\(", re.IGNORECASE), "passport"),
|
|
114
|
+
(re.compile(r"\b(?:verify|require)Token\b", re.IGNORECASE), "token"),
|
|
115
|
+
(re.compile(r"\bauthMiddleware\b", re.IGNORECASE), "auth_middleware"),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
SECRET_PATTERNS = [
|
|
119
|
+
re.compile(r"os\.getenv\(['\"]([^'\"]*(SECRET|TOKEN|KEY|PASSWORD)[^'\"]*)['\"]", re.IGNORECASE),
|
|
120
|
+
re.compile(r"process\.env\.([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)", re.IGNORECASE),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_SNIPPET_MAX_CHARS = 160
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _line_of(content: str, offset: int) -> int:
|
|
128
|
+
"""1-indexed line number for a character offset within content."""
|
|
129
|
+
if offset <= 0:
|
|
130
|
+
return 1
|
|
131
|
+
return content.count("\n", 0, offset) + 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
135
|
+
"""Return the line containing `offset`, stripped and length-capped."""
|
|
136
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
137
|
+
line_end = content.find("\n", offset)
|
|
138
|
+
if line_end == -1:
|
|
139
|
+
line_end = len(content)
|
|
140
|
+
line = content[line_start:line_end].strip()
|
|
141
|
+
if len(line) > max_chars:
|
|
142
|
+
line = line[: max_chars - 1] + "…"
|
|
143
|
+
return line
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def should_scan(path: Path) -> bool:
|
|
147
|
+
if path.name.startswith("."):
|
|
148
|
+
return False
|
|
149
|
+
if any(part in {"node_modules", ".git", ".venv", "dist", "build"} for part in path.parts):
|
|
150
|
+
return False
|
|
151
|
+
return path.suffix in CODE_EXTENSIONS
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def should_scan_with_suffixes(path: Path, suffixes: set[str] | None = None) -> bool:
|
|
155
|
+
if not should_scan(path):
|
|
156
|
+
return False
|
|
157
|
+
if suffixes is None:
|
|
158
|
+
return True
|
|
159
|
+
return path.suffix in suffixes
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalize_path(path: str) -> str:
|
|
163
|
+
"""Ensure path is non-empty and starts with a single forward slash.
|
|
164
|
+
|
|
165
|
+
This guarantees consistent path output across frameworks: FastAPI, Flask,
|
|
166
|
+
and Express can all be invoked with or without a leading ``/``, but
|
|
167
|
+
downstream consumers (path joining, prefix concatenation, attack-surface
|
|
168
|
+
display) rely on a canonical ``/foo/bar`` shape.
|
|
169
|
+
"""
|
|
170
|
+
cleaned = (path or "").strip()
|
|
171
|
+
if not cleaned:
|
|
172
|
+
return "/"
|
|
173
|
+
if not cleaned.startswith("/"):
|
|
174
|
+
cleaned = f"/{cleaned}"
|
|
175
|
+
# Collapse runs of slashes so ``//a//b`` -> ``/a/b``.
|
|
176
|
+
while "//" in cleaned:
|
|
177
|
+
cleaned = cleaned.replace("//", "/")
|
|
178
|
+
return cleaned
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _join_route_parts(prefix: str, path: str) -> str:
|
|
182
|
+
base = _normalize_path(prefix)
|
|
183
|
+
suffix = _normalize_path(path)
|
|
184
|
+
|
|
185
|
+
if base == "/":
|
|
186
|
+
return suffix
|
|
187
|
+
if suffix == "/":
|
|
188
|
+
return base
|
|
189
|
+
return f"{base.rstrip('/')}/{suffix.lstrip('/')}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _extract_methods(args: str, default_method: str = "ANY") -> list[str]:
|
|
193
|
+
methods = [match.upper() for match in METHOD_LIST_PATTERN.findall(args)]
|
|
194
|
+
# "ALL" is a non-standard alias sometimes used in API code; normalize it
|
|
195
|
+
# to the canonical Route(method="ANY") sentinel.
|
|
196
|
+
methods = ["ANY" if m == "ALL" else m for m in methods]
|
|
197
|
+
return methods or [default_method]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _python_route_prefixes(content: str) -> dict[str, str]:
|
|
201
|
+
local_prefixes: dict[str, str] = {}
|
|
202
|
+
|
|
203
|
+
for match in FASTAPI_ROUTER_PATTERN.finditer(content):
|
|
204
|
+
local_prefixes[match.group(1)] = match.group(2) or ""
|
|
205
|
+
|
|
206
|
+
for match in FLASK_BLUEPRINT_PATTERN.finditer(content):
|
|
207
|
+
local_prefixes[match.group(1)] = match.group(2) or ""
|
|
208
|
+
|
|
209
|
+
prefixes = dict(local_prefixes)
|
|
210
|
+
updated = True
|
|
211
|
+
while updated:
|
|
212
|
+
updated = False
|
|
213
|
+
|
|
214
|
+
for match in FASTAPI_INCLUDE_ROUTER_PATTERN.finditer(content):
|
|
215
|
+
parent_name, child_name, extra_prefix = match.groups()
|
|
216
|
+
parent_prefix = prefixes.get(parent_name, "")
|
|
217
|
+
child_prefix = local_prefixes.get(child_name, "")
|
|
218
|
+
combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
|
|
219
|
+
if prefixes.get(child_name) != combined:
|
|
220
|
+
prefixes[child_name] = combined
|
|
221
|
+
updated = True
|
|
222
|
+
|
|
223
|
+
for match in FLASK_REGISTER_BLUEPRINT_PATTERN.finditer(content):
|
|
224
|
+
parent_name, child_name, extra_prefix = match.groups()
|
|
225
|
+
parent_prefix = prefixes.get(parent_name, "")
|
|
226
|
+
child_prefix = local_prefixes.get(child_name, "")
|
|
227
|
+
combined = _join_route_parts(parent_prefix, _join_route_parts(extra_prefix or "", child_prefix))
|
|
228
|
+
if prefixes.get(child_name) != combined:
|
|
229
|
+
prefixes[child_name] = combined
|
|
230
|
+
updated = True
|
|
231
|
+
|
|
232
|
+
return prefixes
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_python_routes(content: str, file: str) -> list[Route]:
|
|
236
|
+
routes: list[Route] = []
|
|
237
|
+
prefixes = _python_route_prefixes(content)
|
|
238
|
+
|
|
239
|
+
for match in FASTAPI_DECORATOR_PATTERN.finditer(content):
|
|
240
|
+
router_name, method, route_path = match.groups()
|
|
241
|
+
routes.append(
|
|
242
|
+
Route(
|
|
243
|
+
path=_join_route_parts(prefixes.get(router_name, ""), route_path),
|
|
244
|
+
method=method.upper(),
|
|
245
|
+
file=file,
|
|
246
|
+
line=_line_of(content, match.start()),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
for match in FASTAPI_API_ROUTE_PATTERN.finditer(content):
|
|
251
|
+
router_name, route_path, args = match.group(1), match.group(2), match.group("args")
|
|
252
|
+
full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
|
|
253
|
+
line = _line_of(content, match.start())
|
|
254
|
+
for method in _extract_methods(args, default_method="ANY"):
|
|
255
|
+
routes.append(Route(path=full_path, method=method, file=file, line=line))
|
|
256
|
+
|
|
257
|
+
for match in FLASK_ROUTE_PATTERN.finditer(content):
|
|
258
|
+
router_name, route_path, args = match.group(1), match.group(2), match.group("args")
|
|
259
|
+
full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
|
|
260
|
+
line = _line_of(content, match.start())
|
|
261
|
+
for method in _extract_methods(args, default_method="GET"):
|
|
262
|
+
routes.append(Route(path=full_path, method=method, file=file, line=line))
|
|
263
|
+
|
|
264
|
+
return routes
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _express_prefixes(content: str) -> dict[str, str]:
|
|
268
|
+
local_prefixes: dict[str, str] = {}
|
|
269
|
+
prefixes: dict[str, str] = {}
|
|
270
|
+
|
|
271
|
+
updated = True
|
|
272
|
+
while updated:
|
|
273
|
+
updated = False
|
|
274
|
+
for match in EXPRESS_USE_PATTERN.finditer(content):
|
|
275
|
+
parent_name, mount_path, child_name = match.groups()
|
|
276
|
+
parent_prefix = prefixes.get(parent_name, "")
|
|
277
|
+
child_prefix = local_prefixes.get(child_name, "")
|
|
278
|
+
local_prefixes[child_name] = child_prefix
|
|
279
|
+
combined = _join_route_parts(parent_prefix, _join_route_parts(mount_path, child_prefix))
|
|
280
|
+
if prefixes.get(child_name) != combined:
|
|
281
|
+
prefixes[child_name] = combined
|
|
282
|
+
updated = True
|
|
283
|
+
|
|
284
|
+
return prefixes
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_javascript_routes(content: str, file: str) -> list[Route]:
|
|
288
|
+
routes: list[Route] = []
|
|
289
|
+
prefixes = _express_prefixes(content)
|
|
290
|
+
|
|
291
|
+
for match in EXPRESS_DIRECT_ROUTE_PATTERN.finditer(content):
|
|
292
|
+
router_name, method, route_path = match.groups()
|
|
293
|
+
method_normalized = method.upper()
|
|
294
|
+
if method_normalized == "ALL":
|
|
295
|
+
method_normalized = "ANY"
|
|
296
|
+
full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
|
|
297
|
+
routes.append(
|
|
298
|
+
Route(
|
|
299
|
+
path=full_path,
|
|
300
|
+
method=method_normalized,
|
|
301
|
+
file=file,
|
|
302
|
+
line=_line_of(content, match.start()),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
for match in EXPRESS_CHAIN_ROUTE_PATTERN.finditer(content):
|
|
307
|
+
router_name, route_path, chain = match.group(1), match.group(2), match.group("chain")
|
|
308
|
+
full_path = _join_route_parts(prefixes.get(router_name, ""), route_path)
|
|
309
|
+
chain_offset = match.start("chain")
|
|
310
|
+
for method_match in re.finditer(r"\.(get|post|put|delete|patch|options|head|all)\s*\(", chain, re.IGNORECASE):
|
|
311
|
+
method = method_match.group(1).upper()
|
|
312
|
+
if method == "ALL":
|
|
313
|
+
method = "ANY"
|
|
314
|
+
routes.append(
|
|
315
|
+
Route(
|
|
316
|
+
path=full_path,
|
|
317
|
+
method=method,
|
|
318
|
+
file=file,
|
|
319
|
+
line=_line_of(content, chain_offset + method_match.start()),
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return routes
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def extract_routes(content: str, file: str, suffix: str) -> list[Route]:
|
|
327
|
+
if suffix == ".py":
|
|
328
|
+
return _extract_python_routes(content, file)
|
|
329
|
+
if suffix in {".js", ".ts", ".tsx"}:
|
|
330
|
+
return _extract_javascript_routes(content, file)
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _append_unique_database_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
|
|
335
|
+
seen = {(hint.kind, hint.file) for hint in result.databases}
|
|
336
|
+
|
|
337
|
+
for pattern, kind in DB_PATTERNS:
|
|
338
|
+
match = pattern.search(content)
|
|
339
|
+
if match and (kind, relative) not in seen:
|
|
340
|
+
result.databases.append(
|
|
341
|
+
DatabaseHint(
|
|
342
|
+
kind=kind,
|
|
343
|
+
file=relative,
|
|
344
|
+
line=_line_of(content, match.start()),
|
|
345
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
seen.add((kind, relative))
|
|
349
|
+
|
|
350
|
+
for keyword, kind in DB_KEYWORDS.items():
|
|
351
|
+
idx = lowered.find(keyword)
|
|
352
|
+
if idx != -1 and (kind, relative) not in seen:
|
|
353
|
+
result.databases.append(
|
|
354
|
+
DatabaseHint(
|
|
355
|
+
kind=kind,
|
|
356
|
+
file=relative,
|
|
357
|
+
line=_line_of(content, idx),
|
|
358
|
+
evidence_text=_line_snippet(content, idx),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
seen.add((kind, relative))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _append_unique_auth_hints(result: ScanResult, relative: str, content: str, lowered: str) -> None:
|
|
365
|
+
seen = {(hint.hint, hint.file) for hint in result.auth_hints}
|
|
366
|
+
|
|
367
|
+
for pattern, hint in AUTH_PATTERNS:
|
|
368
|
+
match = pattern.search(content)
|
|
369
|
+
if match and (hint, relative) not in seen:
|
|
370
|
+
result.auth_hints.append(
|
|
371
|
+
AuthHint(
|
|
372
|
+
hint=hint,
|
|
373
|
+
file=relative,
|
|
374
|
+
line=_line_of(content, match.start()),
|
|
375
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
376
|
+
confidence=0.85,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
seen.add((hint, relative))
|
|
380
|
+
|
|
381
|
+
for keyword in AUTH_KEYWORDS:
|
|
382
|
+
idx = lowered.find(keyword)
|
|
383
|
+
if idx != -1 and (keyword, relative) not in seen:
|
|
384
|
+
result.auth_hints.append(
|
|
385
|
+
AuthHint(
|
|
386
|
+
hint=keyword,
|
|
387
|
+
file=relative,
|
|
388
|
+
line=_line_of(content, idx),
|
|
389
|
+
evidence_text=_line_snippet(content, idx),
|
|
390
|
+
confidence=0.5,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
seen.add((keyword, relative))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def scan_repo(root: str | Path, suffixes: set[str] | None = None) -> ScanResult:
|
|
397
|
+
root_path = Path(root).resolve()
|
|
398
|
+
result = ScanResult(root=str(root_path))
|
|
399
|
+
|
|
400
|
+
for file_path in root_path.rglob("*"):
|
|
401
|
+
if not file_path.is_file() or not should_scan_with_suffixes(file_path, suffixes):
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
result.files_scanned += 1
|
|
405
|
+
language = CODE_EXTENSIONS[file_path.suffix]
|
|
406
|
+
if language not in result.languages:
|
|
407
|
+
result.languages.append(language)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
content = file_path.read_text(encoding="utf-8")
|
|
411
|
+
except UnicodeDecodeError:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
relative = str(file_path.relative_to(root_path))
|
|
415
|
+
file_routes = extract_routes(content, relative, file_path.suffix)
|
|
416
|
+
result.routes.extend(file_routes)
|
|
417
|
+
|
|
418
|
+
for pattern in EXTERNAL_CALL_PATTERNS:
|
|
419
|
+
for match in pattern.finditer(content):
|
|
420
|
+
target = match.groups()[-1]
|
|
421
|
+
result.external_calls.append(
|
|
422
|
+
ExternalCall(
|
|
423
|
+
target=target,
|
|
424
|
+
file=relative,
|
|
425
|
+
line=_line_of(content, match.start()),
|
|
426
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
lowered = content.lower()
|
|
431
|
+
_append_unique_database_hints(result, relative, content, lowered)
|
|
432
|
+
_append_unique_auth_hints(result, relative, content, lowered)
|
|
433
|
+
|
|
434
|
+
for pattern in SECRET_PATTERNS:
|
|
435
|
+
for match in pattern.finditer(content):
|
|
436
|
+
result.secret_hints.append(
|
|
437
|
+
SecretHint(
|
|
438
|
+
name=match.groups()[0],
|
|
439
|
+
file=relative,
|
|
440
|
+
line=_line_of(content, match.start()),
|
|
441
|
+
evidence_text=_line_snippet(content, match.start()),
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
result.languages.sort()
|
|
446
|
+
return result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .contracts import (
|
|
4
|
+
AnalyzerMetadata,
|
|
5
|
+
AnalyzerProtocol,
|
|
6
|
+
AnalyzerRepositoryModule,
|
|
7
|
+
AnalyzerResult,
|
|
8
|
+
normalize_analyzer_metadata,
|
|
9
|
+
)
|
|
10
|
+
from .models import (
|
|
11
|
+
AuthHint,
|
|
12
|
+
DatabaseHint,
|
|
13
|
+
EdgeHint,
|
|
14
|
+
EntrypointHint,
|
|
15
|
+
ExternalCall,
|
|
16
|
+
FrameworkHint,
|
|
17
|
+
ProtocolHint,
|
|
18
|
+
Route,
|
|
19
|
+
ScanResult,
|
|
20
|
+
SecretHint,
|
|
21
|
+
ServiceHint,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AnalyzerResult",
|
|
26
|
+
"AnalyzerMetadata",
|
|
27
|
+
"AnalyzerRepositoryModule",
|
|
28
|
+
"AnalyzerProtocol",
|
|
29
|
+
"normalize_analyzer_metadata",
|
|
30
|
+
"Route",
|
|
31
|
+
"ExternalCall",
|
|
32
|
+
"DatabaseHint",
|
|
33
|
+
"AuthHint",
|
|
34
|
+
"ServiceHint",
|
|
35
|
+
"EdgeHint",
|
|
36
|
+
"EntrypointHint",
|
|
37
|
+
"ProtocolHint",
|
|
38
|
+
"FrameworkHint",
|
|
39
|
+
"SecretHint",
|
|
40
|
+
"ScanResult",
|
|
41
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..analyzer_contracts import (
|
|
4
|
+
AnalyzerMetadata,
|
|
5
|
+
AnalyzerProtocol,
|
|
6
|
+
AnalyzerRepositoryModule,
|
|
7
|
+
AnalyzerResult,
|
|
8
|
+
normalize_analyzer_metadata,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AnalyzerResult",
|
|
13
|
+
"AnalyzerMetadata",
|
|
14
|
+
"AnalyzerRepositoryModule",
|
|
15
|
+
"AnalyzerProtocol",
|
|
16
|
+
"normalize_analyzer_metadata",
|
|
17
|
+
]
|
attackmap/sdk/models.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..recon_models import (
|
|
4
|
+
AuthHint,
|
|
5
|
+
DatabaseHint,
|
|
6
|
+
EdgeHint,
|
|
7
|
+
EntrypointHint,
|
|
8
|
+
ExternalCall,
|
|
9
|
+
FrameworkHint,
|
|
10
|
+
ProtocolHint,
|
|
11
|
+
Route,
|
|
12
|
+
ScanResult,
|
|
13
|
+
SecretHint,
|
|
14
|
+
ServiceHint,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Route",
|
|
19
|
+
"ExternalCall",
|
|
20
|
+
"DatabaseHint",
|
|
21
|
+
"AuthHint",
|
|
22
|
+
"ServiceHint",
|
|
23
|
+
"EdgeHint",
|
|
24
|
+
"EntrypointHint",
|
|
25
|
+
"ProtocolHint",
|
|
26
|
+
"FrameworkHint",
|
|
27
|
+
"SecretHint",
|
|
28
|
+
"ScanResult",
|
|
29
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .asset_model import detect_assets
|
|
6
|
+
from .attack_taxonomy import annotate_findings, annotate_insights
|
|
7
|
+
from .control_model import detect_controls
|
|
8
|
+
from .detection_opportunities import generate_detection_opportunities
|
|
9
|
+
from .insights import generate_insights
|
|
10
|
+
from .models import (
|
|
11
|
+
Asset,
|
|
12
|
+
AttackPath,
|
|
13
|
+
AttackSurface,
|
|
14
|
+
Control,
|
|
15
|
+
DetectionOpportunity,
|
|
16
|
+
Finding,
|
|
17
|
+
Insight,
|
|
18
|
+
ScanResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SecurityOverlay:
|
|
24
|
+
"""Cross-cutting security view layered on top of raw scan signals.
|
|
25
|
+
|
|
26
|
+
Bundles:
|
|
27
|
+
* the asset inventory (`assets`),
|
|
28
|
+
* the defensive control map (`controls`, present + absent),
|
|
29
|
+
* the set of cross-cutting insights connecting them (`insights`),
|
|
30
|
+
* defender-facing detection-engineering hints (`detection_opportunities`),
|
|
31
|
+
* findings with ATT&CK technique mappings layered in (`findings`).
|
|
32
|
+
|
|
33
|
+
Computed once per analyze run and consumed by the JSON report, the
|
|
34
|
+
markdown review, and the LLM prompt pack.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
assets: list[Asset]
|
|
38
|
+
controls: list[Control]
|
|
39
|
+
insights: list[Insight]
|
|
40
|
+
detection_opportunities: list[DetectionOpportunity]
|
|
41
|
+
findings: list[Finding]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_security_overlay(
|
|
45
|
+
scan: ScanResult,
|
|
46
|
+
attack_surfaces: list[AttackSurface],
|
|
47
|
+
findings: list[Finding],
|
|
48
|
+
attack_paths: list[AttackPath],
|
|
49
|
+
) -> SecurityOverlay:
|
|
50
|
+
assets = detect_assets(scan)
|
|
51
|
+
controls = detect_controls(scan, attack_surfaces, assets)
|
|
52
|
+
raw_insights = generate_insights(
|
|
53
|
+
scan,
|
|
54
|
+
attack_surfaces,
|
|
55
|
+
[finding.title for finding in findings],
|
|
56
|
+
attack_paths,
|
|
57
|
+
assets,
|
|
58
|
+
controls,
|
|
59
|
+
)
|
|
60
|
+
insights = annotate_insights(raw_insights)
|
|
61
|
+
annotated_findings = annotate_findings(findings)
|
|
62
|
+
detection_opportunities = generate_detection_opportunities(insights, annotated_findings)
|
|
63
|
+
return SecurityOverlay(
|
|
64
|
+
assets=assets,
|
|
65
|
+
controls=controls,
|
|
66
|
+
insights=insights,
|
|
67
|
+
detection_opportunities=detection_opportunities,
|
|
68
|
+
findings=annotated_findings,
|
|
69
|
+
)
|