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/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)