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/analyzers.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Protocol, TypeVar
|
|
12
|
+
|
|
13
|
+
_T = TypeVar("_T")
|
|
14
|
+
_K = TypeVar("_K")
|
|
15
|
+
from urllib.error import URLError
|
|
16
|
+
from urllib.request import urlopen
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from .sdk.contracts import (
|
|
21
|
+
AnalyzerMetadata,
|
|
22
|
+
AnalyzerProtocol,
|
|
23
|
+
AnalyzerRepositoryModule,
|
|
24
|
+
AnalyzerResult,
|
|
25
|
+
normalize_analyzer_metadata,
|
|
26
|
+
)
|
|
27
|
+
from .sdk.models import (
|
|
28
|
+
AuthHint,
|
|
29
|
+
DatabaseHint,
|
|
30
|
+
EdgeHint,
|
|
31
|
+
EntrypointHint,
|
|
32
|
+
ExternalCall,
|
|
33
|
+
FrameworkHint,
|
|
34
|
+
ProtocolHint,
|
|
35
|
+
Route,
|
|
36
|
+
ScanResult,
|
|
37
|
+
SecretHint,
|
|
38
|
+
ServiceHint,
|
|
39
|
+
)
|
|
40
|
+
from .scanner import (
|
|
41
|
+
AUTH_KEYWORDS,
|
|
42
|
+
AUTH_PATTERNS,
|
|
43
|
+
CODE_EXTENSIONS,
|
|
44
|
+
DB_KEYWORDS,
|
|
45
|
+
DB_PATTERNS,
|
|
46
|
+
EXTERNAL_CALL_PATTERNS,
|
|
47
|
+
SECRET_PATTERNS,
|
|
48
|
+
extract_routes,
|
|
49
|
+
scan_repo,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
ANALYZER_ENTRYPOINT_GROUP = "attackmap.analyzers"
|
|
55
|
+
ANALYZER_ORG_PREFIX = "mlaify/"
|
|
56
|
+
ANALYZER_ORG_BASE_URL = "https://github.com/mlaify"
|
|
57
|
+
ANALYZER_ORG_API_URL = "https://api.github.com/orgs/mlaify/repos?per_page=100&type=public"
|
|
58
|
+
|
|
59
|
+
# Backward-compatible structured signal contract used by the lower-level scanner tests.
|
|
60
|
+
class AnalyzerSignals(BaseModel):
|
|
61
|
+
routes: list[Route] = Field(default_factory=list)
|
|
62
|
+
external_calls: list[ExternalCall] = Field(default_factory=list)
|
|
63
|
+
databases: list[DatabaseHint] = Field(default_factory=list)
|
|
64
|
+
auth_hints: list[AuthHint] = Field(default_factory=list)
|
|
65
|
+
secret_hints: list[SecretHint] = Field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class AnalyzerContext:
|
|
70
|
+
root_path: Path
|
|
71
|
+
file_path: Path
|
|
72
|
+
relative_path: str
|
|
73
|
+
content: str
|
|
74
|
+
suffix: str
|
|
75
|
+
language: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FileAnalyzer(Protocol):
|
|
79
|
+
name: str
|
|
80
|
+
|
|
81
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RouteAnalyzer:
|
|
85
|
+
name = "routes"
|
|
86
|
+
|
|
87
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
|
|
88
|
+
return AnalyzerSignals(routes=extract_routes(context.content, context.relative_path, context.suffix))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ExternalCallAnalyzer:
|
|
92
|
+
name = "external_calls"
|
|
93
|
+
|
|
94
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
|
|
95
|
+
calls: list[ExternalCall] = []
|
|
96
|
+
for pattern in EXTERNAL_CALL_PATTERNS:
|
|
97
|
+
for match in pattern.finditer(context.content):
|
|
98
|
+
calls.append(ExternalCall(target=match.groups()[-1], file=context.relative_path))
|
|
99
|
+
return AnalyzerSignals(external_calls=calls)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DatabaseAnalyzer:
|
|
103
|
+
name = "databases"
|
|
104
|
+
|
|
105
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
|
|
106
|
+
lowered = context.content.lower()
|
|
107
|
+
databases: list[DatabaseHint] = []
|
|
108
|
+
seen: set[tuple[str, str]] = set()
|
|
109
|
+
|
|
110
|
+
for pattern, kind in DB_PATTERNS:
|
|
111
|
+
if pattern.search(context.content) and (kind, context.relative_path) not in seen:
|
|
112
|
+
databases.append(DatabaseHint(kind=kind, file=context.relative_path))
|
|
113
|
+
seen.add((kind, context.relative_path))
|
|
114
|
+
|
|
115
|
+
for keyword, kind in DB_KEYWORDS.items():
|
|
116
|
+
if keyword in lowered and (kind, context.relative_path) not in seen:
|
|
117
|
+
databases.append(DatabaseHint(kind=kind, file=context.relative_path))
|
|
118
|
+
seen.add((kind, context.relative_path))
|
|
119
|
+
|
|
120
|
+
return AnalyzerSignals(databases=databases)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AuthAnalyzer:
|
|
124
|
+
name = "auth"
|
|
125
|
+
|
|
126
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
|
|
127
|
+
lowered = context.content.lower()
|
|
128
|
+
auth_hints: list[AuthHint] = []
|
|
129
|
+
seen: set[tuple[str, str]] = set()
|
|
130
|
+
|
|
131
|
+
for pattern, hint in AUTH_PATTERNS:
|
|
132
|
+
if pattern.search(context.content) and (hint, context.relative_path) not in seen:
|
|
133
|
+
auth_hints.append(AuthHint(hint=hint, file=context.relative_path))
|
|
134
|
+
seen.add((hint, context.relative_path))
|
|
135
|
+
|
|
136
|
+
for keyword in AUTH_KEYWORDS:
|
|
137
|
+
if keyword in lowered and (keyword, context.relative_path) not in seen:
|
|
138
|
+
auth_hints.append(AuthHint(hint=keyword, file=context.relative_path))
|
|
139
|
+
seen.add((keyword, context.relative_path))
|
|
140
|
+
|
|
141
|
+
return AnalyzerSignals(auth_hints=auth_hints)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SecretAnalyzer:
|
|
145
|
+
name = "secrets"
|
|
146
|
+
|
|
147
|
+
def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
|
|
148
|
+
secret_hints: list[SecretHint] = []
|
|
149
|
+
for pattern in SECRET_PATTERNS:
|
|
150
|
+
for match in pattern.finditer(context.content):
|
|
151
|
+
secret_hints.append(SecretHint(name=match.groups()[0], file=context.relative_path))
|
|
152
|
+
return AnalyzerSignals(secret_hints=secret_hints)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
FILE_ANALYZERS: tuple[FileAnalyzer, ...] = (
|
|
156
|
+
RouteAnalyzer(),
|
|
157
|
+
ExternalCallAnalyzer(),
|
|
158
|
+
DatabaseAnalyzer(),
|
|
159
|
+
AuthAnalyzer(),
|
|
160
|
+
SecretAnalyzer(),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_builtin_analyzers() -> tuple[FileAnalyzer, ...]:
|
|
165
|
+
return FILE_ANALYZERS
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def merge_analyzer_signals(scan: ScanResult, signals: AnalyzerSignals) -> None:
|
|
169
|
+
scan.routes.extend(signals.routes)
|
|
170
|
+
scan.external_calls.extend(signals.external_calls)
|
|
171
|
+
scan.secret_hints.extend(signals.secret_hints)
|
|
172
|
+
|
|
173
|
+
seen_databases = {(hint.kind, hint.file) for hint in scan.databases}
|
|
174
|
+
for hint in signals.databases:
|
|
175
|
+
key = (hint.kind, hint.file)
|
|
176
|
+
if key not in seen_databases:
|
|
177
|
+
scan.databases.append(hint)
|
|
178
|
+
seen_databases.add(key)
|
|
179
|
+
|
|
180
|
+
seen_auth_hints = {(hint.hint, hint.file) for hint in scan.auth_hints}
|
|
181
|
+
for hint in signals.auth_hints:
|
|
182
|
+
key = (hint.hint, hint.file)
|
|
183
|
+
if key not in seen_auth_hints:
|
|
184
|
+
scan.auth_hints.append(hint)
|
|
185
|
+
seen_auth_hints.add(key)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Backward-compatible alias for existing imports from attackmap.analyzers.
|
|
189
|
+
Analyzer = AnalyzerProtocol
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class DefaultAnalyzer:
|
|
193
|
+
metadata = AnalyzerMetadata(
|
|
194
|
+
name="default",
|
|
195
|
+
display_name="Default Analyzer",
|
|
196
|
+
version="0.1.0",
|
|
197
|
+
description="Fallback built-in analyzer for the remaining scanner-backed ecosystems.",
|
|
198
|
+
scope="Fallback scanner coverage for supported TypeScript code paths not yet handled by a specialized analyzer.",
|
|
199
|
+
targets=[],
|
|
200
|
+
languages=["typescript"],
|
|
201
|
+
priority=100,
|
|
202
|
+
experimental=False,
|
|
203
|
+
enabled_by_default=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def name(self) -> str:
|
|
208
|
+
return self.metadata.name
|
|
209
|
+
|
|
210
|
+
def analyze(self, root: str | Path) -> AnalyzerResult:
|
|
211
|
+
return scan_repo(root, suffixes=set(CODE_EXTENSIONS) - {".py", ".js"})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class BuiltinPythonWebAnalyzer:
|
|
215
|
+
metadata = AnalyzerMetadata(
|
|
216
|
+
name="python-web",
|
|
217
|
+
display_name="Python Web Analyzer",
|
|
218
|
+
version="0.1.0",
|
|
219
|
+
description="Built-in analyzer for Python web frameworks and related security signals.",
|
|
220
|
+
scope="Python source files handled by the current scanner-backed web heuristics.",
|
|
221
|
+
targets=["fastapi", "flask"],
|
|
222
|
+
languages=["python"],
|
|
223
|
+
priority=20,
|
|
224
|
+
experimental=False,
|
|
225
|
+
enabled_by_default=True,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def name(self) -> str:
|
|
230
|
+
return self.metadata.name
|
|
231
|
+
|
|
232
|
+
def analyze(self, root: str | Path) -> AnalyzerResult:
|
|
233
|
+
return scan_repo(root, suffixes={".py"})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class BuiltinJavaScriptWebAnalyzer:
|
|
237
|
+
metadata = AnalyzerMetadata(
|
|
238
|
+
name="javascript-web",
|
|
239
|
+
display_name="JavaScript Web Analyzer",
|
|
240
|
+
version="0.1.0",
|
|
241
|
+
description="Built-in analyzer for JavaScript web frameworks and related security signals.",
|
|
242
|
+
scope="JavaScript source files handled by the current scanner-backed web heuristics.",
|
|
243
|
+
targets=["express", "node"],
|
|
244
|
+
languages=["javascript"],
|
|
245
|
+
priority=20,
|
|
246
|
+
experimental=False,
|
|
247
|
+
enabled_by_default=True,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def name(self) -> str:
|
|
252
|
+
return self.metadata.name
|
|
253
|
+
|
|
254
|
+
def analyze(self, root: str | Path) -> AnalyzerResult:
|
|
255
|
+
return scan_repo(root, suffixes={".js"})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_builtin_repository_analyzers() -> list[Analyzer]:
|
|
259
|
+
return [BuiltinPythonWebAnalyzer(), BuiltinJavaScriptWebAnalyzer(), DefaultAnalyzer()]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def discover_installed_analyzers(group: str = ANALYZER_ENTRYPOINT_GROUP) -> list[Analyzer]:
|
|
263
|
+
discovered: list[Analyzer] = []
|
|
264
|
+
all_entry_points = entry_points()
|
|
265
|
+
if hasattr(all_entry_points, "select"):
|
|
266
|
+
candidates = list(all_entry_points.select(group=group))
|
|
267
|
+
else:
|
|
268
|
+
candidates = list(all_entry_points.get(group, ()))
|
|
269
|
+
|
|
270
|
+
for analyzer_entry_point in sorted(candidates, key=lambda candidate: candidate.name):
|
|
271
|
+
analyzer = _load_discovered_analyzer(analyzer_entry_point)
|
|
272
|
+
if analyzer is None:
|
|
273
|
+
continue
|
|
274
|
+
discovered.append(analyzer)
|
|
275
|
+
return discovered
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _load_discovered_analyzer(analyzer_entry_point: object) -> Analyzer | None:
|
|
279
|
+
entry_name = getattr(analyzer_entry_point, "name", "<unknown>")
|
|
280
|
+
try:
|
|
281
|
+
loaded_object = analyzer_entry_point.load()
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
logger.warning("Failed to load analyzer entry point '%s': %s", entry_name, exc)
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
analyzer = _coerce_analyzer_instance(loaded_object)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
logger.warning("Failed to initialize analyzer entry point '%s': %s", entry_name, exc)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
canonical_metadata = normalize_analyzer_metadata(getattr(analyzer, "metadata", None))
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
logger.warning("Failed to normalize analyzer metadata for '%s': %s", entry_name, exc)
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
setattr(analyzer, "metadata", canonical_metadata)
|
|
299
|
+
except Exception:
|
|
300
|
+
# Some analyzers may expose read-only metadata attributes; normalization is still validated.
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
if not _is_valid_analyzer(analyzer):
|
|
304
|
+
logger.warning("Skipping entry point '%s': loaded object is not a valid analyzer.", entry_name)
|
|
305
|
+
return None
|
|
306
|
+
return analyzer
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _coerce_analyzer_instance(loaded_object: object) -> object:
|
|
310
|
+
if isinstance(loaded_object, type):
|
|
311
|
+
return loaded_object()
|
|
312
|
+
if hasattr(loaded_object, "analyze") and hasattr(loaded_object, "metadata"):
|
|
313
|
+
return loaded_object
|
|
314
|
+
if callable(loaded_object):
|
|
315
|
+
return loaded_object()
|
|
316
|
+
return loaded_object
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_valid_analyzer(candidate: object) -> bool:
|
|
320
|
+
if not hasattr(candidate, "metadata") or not hasattr(candidate, "analyze") or not callable(candidate.analyze):
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
candidate_name = getattr(candidate, "name", None)
|
|
324
|
+
if not isinstance(candidate_name, str) or not candidate_name.strip():
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
metadata = normalize_analyzer_metadata(getattr(candidate, "metadata"))
|
|
329
|
+
except Exception:
|
|
330
|
+
return False
|
|
331
|
+
return isinstance(metadata.name, str) and bool(metadata.name.strip())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def get_registered_analyzers() -> list[Analyzer]:
|
|
335
|
+
analyzers = [*get_builtin_repository_analyzers(), *discover_installed_analyzers()]
|
|
336
|
+
deduplicated: list[Analyzer] = []
|
|
337
|
+
seen_names: set[str] = set()
|
|
338
|
+
|
|
339
|
+
for analyzer in analyzers:
|
|
340
|
+
if analyzer.name in seen_names:
|
|
341
|
+
logger.warning("Skipping duplicate analyzer name '%s'.", analyzer.name)
|
|
342
|
+
continue
|
|
343
|
+
seen_names.add(analyzer.name)
|
|
344
|
+
deduplicated.append(analyzer)
|
|
345
|
+
|
|
346
|
+
return deduplicated
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def select_requested_analyzers(
|
|
350
|
+
requested_modules: Iterable[str],
|
|
351
|
+
*,
|
|
352
|
+
auto_install: bool = False,
|
|
353
|
+
installer: Callable[[str], None] | None = None,
|
|
354
|
+
) -> list[Analyzer]:
|
|
355
|
+
requested_names = [_normalize_analyzer_name(module) for module in requested_modules if module.strip()]
|
|
356
|
+
if not requested_names:
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
resolved = _match_requested_analyzers(requested_names)
|
|
360
|
+
missing_names = [name for name in requested_names if name not in resolved]
|
|
361
|
+
|
|
362
|
+
if missing_names and auto_install:
|
|
363
|
+
install_fn = installer if installer is not None else install_analyzer_module
|
|
364
|
+
for missing_name in missing_names:
|
|
365
|
+
try:
|
|
366
|
+
install_fn(_derive_repo_name(missing_name))
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
raise ValueError(f"Failed to install analyzer module '{missing_name}': {exc}") from exc
|
|
369
|
+
resolved = _match_requested_analyzers(requested_names)
|
|
370
|
+
missing_names = [name for name in requested_names if name not in resolved]
|
|
371
|
+
|
|
372
|
+
if missing_names:
|
|
373
|
+
missing_text = ", ".join(missing_names)
|
|
374
|
+
raise ValueError(f"Requested analyzer module(s) not available: {missing_text}")
|
|
375
|
+
|
|
376
|
+
selected: list[Analyzer] = []
|
|
377
|
+
seen: set[str] = set()
|
|
378
|
+
for name in requested_names:
|
|
379
|
+
if name in seen:
|
|
380
|
+
continue
|
|
381
|
+
seen.add(name)
|
|
382
|
+
selected.append(resolved[name])
|
|
383
|
+
return selected
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def install_analyzer_module(repo_name: str) -> None:
|
|
387
|
+
normalized_repo = _normalize_repo_name(repo_name)
|
|
388
|
+
module_url = f"git+{ANALYZER_ORG_BASE_URL}/{normalized_repo}.git"
|
|
389
|
+
logger.info("Installing analyzer module from %s", module_url)
|
|
390
|
+
subprocess.run(
|
|
391
|
+
[sys.executable, "-m", "pip", "install", module_url],
|
|
392
|
+
check=True,
|
|
393
|
+
capture_output=True,
|
|
394
|
+
text=True,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _match_requested_analyzers(requested_names: Iterable[str]) -> dict[str, Analyzer]:
|
|
399
|
+
available = {analyzer.name: analyzer for analyzer in get_registered_analyzers()}
|
|
400
|
+
return {name: available[name] for name in requested_names if name in available}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _normalize_analyzer_name(module: str) -> str:
|
|
404
|
+
normalized = module.strip().lower().removesuffix(".git")
|
|
405
|
+
if normalized.startswith(ANALYZER_ORG_PREFIX):
|
|
406
|
+
normalized = normalized[len(ANALYZER_ORG_PREFIX) :]
|
|
407
|
+
if "/" in normalized:
|
|
408
|
+
normalized = normalized.rsplit("/", 1)[-1]
|
|
409
|
+
if normalized.startswith("attackmap-analyzer-"):
|
|
410
|
+
normalized = normalized.removeprefix("attackmap-analyzer-")
|
|
411
|
+
return normalized
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _normalize_repo_name(repo_name: str) -> str:
|
|
415
|
+
normalized = repo_name.strip().lower().removesuffix(".git")
|
|
416
|
+
if normalized.startswith(ANALYZER_ORG_PREFIX):
|
|
417
|
+
normalized = normalized[len(ANALYZER_ORG_PREFIX) :]
|
|
418
|
+
if "/" in normalized:
|
|
419
|
+
normalized = normalized.rsplit("/", 1)[-1]
|
|
420
|
+
if normalized.startswith("attackmap-analyzer-"):
|
|
421
|
+
return normalized
|
|
422
|
+
return f"attackmap-analyzer-{normalized}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _derive_repo_name(analyzer_name: str) -> str:
|
|
426
|
+
return _normalize_repo_name(analyzer_name)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def get_analyzer_metadata(analyzer: Analyzer) -> AnalyzerMetadata:
|
|
430
|
+
return normalize_analyzer_metadata(analyzer.metadata)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_available_modules() -> list[AnalyzerMetadata]:
|
|
434
|
+
return [get_analyzer_metadata(analyzer) for analyzer in get_registered_analyzers()]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def get_available_repository_modules(
|
|
438
|
+
*,
|
|
439
|
+
fetcher: Callable[[str], list[dict[str, object]]] | None = None,
|
|
440
|
+
) -> list[AnalyzerRepositoryModule]:
|
|
441
|
+
fetch_fn = fetcher if fetcher is not None else _fetch_org_repositories
|
|
442
|
+
projects = fetch_fn(ANALYZER_ORG_API_URL)
|
|
443
|
+
modules: list[AnalyzerRepositoryModule] = []
|
|
444
|
+
for project in projects:
|
|
445
|
+
repo_name = str(project.get("name", "")).strip()
|
|
446
|
+
if not repo_name.startswith("attackmap-analyzer-"):
|
|
447
|
+
continue
|
|
448
|
+
analyzer_name = _normalize_analyzer_name(repo_name)
|
|
449
|
+
web_url = str(project.get("html_url", f"{ANALYZER_ORG_BASE_URL}/{repo_name}")).strip()
|
|
450
|
+
modules.append(
|
|
451
|
+
AnalyzerRepositoryModule(
|
|
452
|
+
analyzer_name=analyzer_name,
|
|
453
|
+
repo_name=repo_name,
|
|
454
|
+
web_url=web_url,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
modules.sort(key=lambda module: module.analyzer_name)
|
|
458
|
+
return modules
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _fetch_org_repositories(api_url: str) -> list[dict[str, object]]:
|
|
462
|
+
try:
|
|
463
|
+
with urlopen(api_url, timeout=10) as response:
|
|
464
|
+
payload = response.read().decode("utf-8")
|
|
465
|
+
except URLError as exc:
|
|
466
|
+
raise ValueError(f"Unable to reach analyzer module registry: {exc}") from exc
|
|
467
|
+
try:
|
|
468
|
+
decoded = json.loads(payload)
|
|
469
|
+
except json.JSONDecodeError as exc:
|
|
470
|
+
raise ValueError(f"Invalid analyzer module registry response: {exc}") from exc
|
|
471
|
+
if not isinstance(decoded, list):
|
|
472
|
+
raise ValueError("Unexpected analyzer module registry response shape.")
|
|
473
|
+
return [item for item in decoded if isinstance(item, dict)]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def analyze_repository(root: str | Path, analyzers: Iterable[Analyzer] | None = None) -> AnalyzerResult:
|
|
477
|
+
repo_root = Path(root).resolve()
|
|
478
|
+
active_analyzers = resolve_run_analyzers(repo_root, analyzers=analyzers)
|
|
479
|
+
results = [analyzer.analyze(repo_root) for analyzer in active_analyzers]
|
|
480
|
+
if not results:
|
|
481
|
+
return AnalyzerResult(root=str(repo_root))
|
|
482
|
+
return merge_analyzer_results(results, root=repo_root)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def resolve_run_analyzers(root: str | Path, analyzers: Iterable[Analyzer] | None = None) -> list[Analyzer]:
|
|
486
|
+
repo_root = Path(root).resolve()
|
|
487
|
+
registered = list(analyzers) if analyzers is not None else get_registered_analyzers()
|
|
488
|
+
return [analyzer for analyzer in registered if _should_run_analyzer(analyzer, repo_root)]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _should_run_analyzer(analyzer: Analyzer, repo_root: Path) -> bool:
|
|
492
|
+
detect_fn = getattr(analyzer, "detect", None)
|
|
493
|
+
if detect_fn is None:
|
|
494
|
+
return True
|
|
495
|
+
if not callable(detect_fn):
|
|
496
|
+
return True
|
|
497
|
+
try:
|
|
498
|
+
return bool(detect_fn(repo_root))
|
|
499
|
+
except Exception as exc:
|
|
500
|
+
logger.warning("Analyzer '%s' detect() failed: %s", analyzer.name, exc)
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def merge_analyzer_results(
|
|
505
|
+
results: Iterable[AnalyzerResult],
|
|
506
|
+
root: str | Path | None = None,
|
|
507
|
+
) -> AnalyzerResult:
|
|
508
|
+
result_list = list(results)
|
|
509
|
+
if not result_list:
|
|
510
|
+
resolved_root = Path(root).resolve() if root is not None else Path(".").resolve()
|
|
511
|
+
return AnalyzerResult(root=str(resolved_root))
|
|
512
|
+
|
|
513
|
+
resolved_root = Path(root).resolve() if root is not None else Path(result_list[0].root).resolve()
|
|
514
|
+
merged = AnalyzerResult(root=str(resolved_root))
|
|
515
|
+
route_keys: set[tuple[str, str, str]] = set()
|
|
516
|
+
external_keys: set[tuple[str, str]] = set()
|
|
517
|
+
database_keys: set[tuple[str, str]] = set()
|
|
518
|
+
auth_keys: set[tuple[str, str]] = set()
|
|
519
|
+
service_keys: set[tuple[str, str]] = set()
|
|
520
|
+
edge_keys: set[tuple[str, str]] = set()
|
|
521
|
+
entrypoint_keys: set[tuple[str, str]] = set()
|
|
522
|
+
protocol_keys: set[tuple[str, str]] = set()
|
|
523
|
+
framework_keys: set[tuple[str, str]] = set()
|
|
524
|
+
secret_keys: set[tuple[str, str]] = set()
|
|
525
|
+
|
|
526
|
+
for result in result_list:
|
|
527
|
+
merged.files_scanned += result.files_scanned
|
|
528
|
+
for language in result.languages:
|
|
529
|
+
if language not in merged.languages:
|
|
530
|
+
merged.languages.append(language)
|
|
531
|
+
_merge_unique_items(merged.routes, route_keys, result.routes, lambda item: (item.path, item.method, item.file))
|
|
532
|
+
_merge_unique_items(merged.external_calls, external_keys, result.external_calls, lambda item: (item.target, item.file))
|
|
533
|
+
_merge_unique_items(merged.databases, database_keys, result.databases, lambda item: (item.kind, item.file))
|
|
534
|
+
_merge_unique_items(merged.auth_hints, auth_keys, result.auth_hints, lambda item: (item.hint, item.file))
|
|
535
|
+
_merge_unique_items(merged.service_hints, service_keys, result.service_hints, lambda item: (item.hint, item.file))
|
|
536
|
+
_merge_unique_items(merged.edge_hints, edge_keys, result.edge_hints, lambda item: (item.hint, item.file))
|
|
537
|
+
_merge_unique_items(
|
|
538
|
+
merged.entrypoint_hints, entrypoint_keys, result.entrypoint_hints, lambda item: (item.hint, item.file)
|
|
539
|
+
)
|
|
540
|
+
_merge_unique_items(merged.protocol_hints, protocol_keys, result.protocol_hints, lambda item: (item.hint, item.file))
|
|
541
|
+
_merge_unique_items(
|
|
542
|
+
merged.framework_hints, framework_keys, result.framework_hints, lambda item: (item.hint, item.file)
|
|
543
|
+
)
|
|
544
|
+
_merge_unique_items(merged.secret_hints, secret_keys, result.secret_hints, lambda item: (item.name, item.file))
|
|
545
|
+
|
|
546
|
+
merged.languages.sort()
|
|
547
|
+
return merged
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _merge_unique_items(
|
|
551
|
+
destination: list[_T],
|
|
552
|
+
seen: set[_K],
|
|
553
|
+
items: Iterable[_T],
|
|
554
|
+
key_fn: Callable[[_T], _K],
|
|
555
|
+
) -> None:
|
|
556
|
+
for item in items:
|
|
557
|
+
key = key_fn(item)
|
|
558
|
+
if key in seen:
|
|
559
|
+
continue
|
|
560
|
+
seen.add(key)
|
|
561
|
+
destination.append(item)
|