attackmap 0.6.4__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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/analyzers.py ADDED
@@ -0,0 +1,1003 @@
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 importlib
7
+ import inspect
8
+ import json
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from typing import Protocol
16
+
17
+ from urllib.error import URLError
18
+ from urllib.request import urlopen
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from .progress import ScanProgress
23
+ from .srcpaths import JS_TS_SUFFIXES, SKIP_DIRS, in_skipped_dir, is_skipped_dir
24
+ from .plugins_lock import OFFICIAL_PLUGINS
25
+ from .safe_fs import walk_repo
26
+ from .merge import MERGE_SCHEMA, initial_seen, merge_into, superseded_builtin_routes
27
+
28
+ from .sdk.contracts import (
29
+ AnalyzerMetadata,
30
+ AnalyzerProtocol,
31
+ AnalyzerRepositoryModule,
32
+ AnalyzerResult,
33
+ normalize_analyzer_metadata,
34
+ )
35
+ from .models import (
36
+ APP_SURFACE_ISSUE_KINDS,
37
+ CLOUD_MISCONFIG_KINDS,
38
+ CODE_WEAKNESS_KINDS,
39
+ DEPENDENCY_ECOSYSTEMS,
40
+ AnalyzerError,
41
+ )
42
+ from .redact import redact_text
43
+ from .sdk.models import (
44
+ AuthHint,
45
+ DatabaseHint,
46
+ ExternalCall,
47
+ Route,
48
+ ScanResult,
49
+ SecretHint,
50
+ )
51
+ from .scanner import (
52
+ AUTH_KEYWORDS,
53
+ AUTH_PATTERNS,
54
+ CODE_EXTENSIONS,
55
+ DB_KEYWORDS,
56
+ DB_PATTERNS,
57
+ EXTERNAL_CALL_PATTERNS,
58
+ _external_call_fields,
59
+ SECRET_PATTERNS,
60
+ extract_routes,
61
+ run_repo_passes,
62
+ scan_files,
63
+ )
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+ ANALYZER_ENTRYPOINT_GROUP = "attackmap.analyzers"
68
+
69
+ # Directories excluded from `detect()` walks in built-in analyzers.
70
+ # Mirrors the scanner's own scan-time filter — anything below these
71
+ # paths is vendored / generated and shouldn't count toward "this repo
72
+ # looks like JS/TS."
73
+ _SKIP_DIRS = SKIP_DIRS # shared set (#215)
74
+ ANALYZER_ORG_PREFIX = "mlaify/"
75
+ ANALYZER_ORG_BASE_URL = "https://github.com/mlaify"
76
+ ANALYZER_ORG_API_URL = "https://api.github.com/orgs/mlaify/repos?per_page=100&type=public"
77
+
78
+ # Backward-compatible structured signal contract used by the lower-level scanner tests.
79
+ class AnalyzerSignals(BaseModel):
80
+ routes: list[Route] = Field(default_factory=list)
81
+ external_calls: list[ExternalCall] = Field(default_factory=list)
82
+ databases: list[DatabaseHint] = Field(default_factory=list)
83
+ auth_hints: list[AuthHint] = Field(default_factory=list)
84
+ secret_hints: list[SecretHint] = Field(default_factory=list)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class AnalyzerContext:
89
+ root_path: Path
90
+ file_path: Path
91
+ relative_path: str
92
+ content: str
93
+ suffix: str
94
+ language: str
95
+
96
+
97
+ class FileAnalyzer(Protocol):
98
+ name: str
99
+
100
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals: ...
101
+
102
+
103
+ class RouteAnalyzer:
104
+ name = "routes"
105
+
106
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
107
+ return AnalyzerSignals(routes=extract_routes(context.content, context.relative_path, context.suffix))
108
+
109
+
110
+ class ExternalCallAnalyzer:
111
+ name = "external_calls"
112
+
113
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
114
+ calls: list[ExternalCall] = []
115
+ for pattern in EXTERNAL_CALL_PATTERNS:
116
+ for match in pattern.finditer(context.content):
117
+ target, method = _external_call_fields(match)
118
+ calls.append(
119
+ ExternalCall(target=target, method=method, file=context.relative_path)
120
+ )
121
+ return AnalyzerSignals(external_calls=calls)
122
+
123
+
124
+ class DatabaseAnalyzer:
125
+ name = "databases"
126
+
127
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
128
+ lowered = context.content.lower()
129
+ databases: list[DatabaseHint] = []
130
+ seen: set[tuple[str, str]] = set()
131
+
132
+ for pattern, kind in DB_PATTERNS:
133
+ if pattern.search(context.content) and (kind, context.relative_path) not in seen:
134
+ databases.append(DatabaseHint(kind=kind, file=context.relative_path))
135
+ seen.add((kind, context.relative_path))
136
+
137
+ for keyword, kind in DB_KEYWORDS.items():
138
+ if keyword in lowered and (kind, context.relative_path) not in seen:
139
+ databases.append(DatabaseHint(kind=kind, file=context.relative_path))
140
+ seen.add((kind, context.relative_path))
141
+
142
+ return AnalyzerSignals(databases=databases)
143
+
144
+
145
+ class AuthAnalyzer:
146
+ name = "auth"
147
+
148
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
149
+ lowered = context.content.lower()
150
+ auth_hints: list[AuthHint] = []
151
+ seen: set[tuple[str, str]] = set()
152
+
153
+ for pattern, hint in AUTH_PATTERNS:
154
+ if pattern.search(context.content) and (hint, context.relative_path) not in seen:
155
+ auth_hints.append(AuthHint(hint=hint, file=context.relative_path))
156
+ seen.add((hint, context.relative_path))
157
+
158
+ for keyword in AUTH_KEYWORDS:
159
+ if keyword in lowered and (keyword, context.relative_path) not in seen:
160
+ auth_hints.append(AuthHint(hint=keyword, file=context.relative_path))
161
+ seen.add((keyword, context.relative_path))
162
+
163
+ return AnalyzerSignals(auth_hints=auth_hints)
164
+
165
+
166
+ class SecretAnalyzer:
167
+ name = "secrets"
168
+
169
+ def analyze(self, context: AnalyzerContext) -> AnalyzerSignals:
170
+ secret_hints: list[SecretHint] = []
171
+ for pattern in SECRET_PATTERNS:
172
+ for match in pattern.finditer(context.content):
173
+ secret_hints.append(SecretHint(name=match.groups()[0], file=context.relative_path))
174
+ return AnalyzerSignals(secret_hints=secret_hints)
175
+
176
+
177
+ FILE_ANALYZERS: tuple[FileAnalyzer, ...] = (
178
+ RouteAnalyzer(),
179
+ ExternalCallAnalyzer(),
180
+ DatabaseAnalyzer(),
181
+ AuthAnalyzer(),
182
+ SecretAnalyzer(),
183
+ )
184
+
185
+
186
+ def get_builtin_analyzers() -> tuple[FileAnalyzer, ...]:
187
+ return FILE_ANALYZERS
188
+
189
+
190
+ def merge_analyzer_signals(scan: ScanResult, signals: AnalyzerSignals) -> None:
191
+ """Apply file-by-file analyzer signals onto a running scan result.
192
+
193
+ `AnalyzerSignals` is the narrow per-file shape used by the built-in
194
+ micro-analyzers in this module. Dedup follows :data:`merge.MERGE_SCHEMA`
195
+ for the field families it covers; routes, external calls, and secrets
196
+ are appended as-is, with no dedup against what the scan already holds.
197
+ Callers that apply several batches for the same file must dedup those
198
+ fields themselves.
199
+ """
200
+ scan.routes.extend(signals.routes)
201
+ scan.external_calls.extend(signals.external_calls)
202
+ scan.secret_hints.extend(signals.secret_hints)
203
+
204
+ rules = {r.attr: r for r in MERGE_SCHEMA}
205
+ for attr in ("databases", "auth_hints"):
206
+ rule = rules[attr]
207
+ merge_into(scan, getattr(signals, attr), rule, initial_seen(scan, rule))
208
+
209
+
210
+ # Backward-compatible alias for existing imports from attackmap.analyzers.
211
+ Analyzer = AnalyzerProtocol
212
+
213
+
214
+ class DefaultAnalyzer:
215
+ """Catch-all for code file suffixes not claimed by a specialized
216
+ built-in analyzer. In practice this is the built-in Go/PHP analyzer:
217
+ CODE_EXTENSIONS includes `.go` and `.php`, which neither python-web nor
218
+ javascript-web claims. Any suffix added to CODE_EXTENSIONS later is
219
+ picked up here until a dedicated built-in ships."""
220
+
221
+ # Suffixes owned by specialized built-in analyzers. Kept in sync
222
+ # with BuiltinPythonWebAnalyzer + BuiltinJavaScriptWebAnalyzer.
223
+ _CLAIMED_SUFFIXES = {".py"} | JS_TS_SUFFIXES
224
+
225
+ metadata = AnalyzerMetadata(
226
+ name="default",
227
+ display_name="Default Analyzer",
228
+ version="0.1.0",
229
+ description="Fallback built-in analyzer for code file suffixes not claimed by a specialized built-in.",
230
+ scope="Any CODE_EXTENSIONS suffix not covered by python-web or javascript-web.",
231
+ targets=[],
232
+ languages=[],
233
+ priority=100,
234
+ experimental=False,
235
+ enabled_by_default=True,
236
+ )
237
+
238
+ @property
239
+ def name(self) -> str:
240
+ return self.metadata.name
241
+
242
+ def analyze(
243
+ self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
244
+ ) -> AnalyzerResult:
245
+ return scan_files(
246
+ root,
247
+ suffixes=set(CODE_EXTENSIONS) - self._CLAIMED_SUFFIXES,
248
+ progress=progress,
249
+ )
250
+
251
+
252
+ class BuiltinPythonWebAnalyzer:
253
+ metadata = AnalyzerMetadata(
254
+ name="python-web",
255
+ display_name="Python Web Analyzer",
256
+ version="0.1.0",
257
+ description="Built-in analyzer for Python web frameworks and related security signals.",
258
+ scope="Python source files handled by the current scanner-backed web heuristics.",
259
+ targets=["fastapi", "flask"],
260
+ languages=["python"],
261
+ priority=20,
262
+ experimental=False,
263
+ enabled_by_default=True,
264
+ )
265
+
266
+ @property
267
+ def name(self) -> str:
268
+ return self.metadata.name
269
+
270
+ def analyze(
271
+ self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
272
+ ) -> AnalyzerResult:
273
+ return scan_files(root, suffixes={".py"}, progress=progress)
274
+
275
+
276
+ class BuiltinJavaScriptWebAnalyzer:
277
+ """Broad built-in JavaScript / TypeScript web analyzer.
278
+
279
+ Intentionally shallow — this is the *fallback* that any JS/TS repo
280
+ gets even when no plugin is installed. Deep coverage (NestJS, tRPC,
281
+ XRPC, workspaces, BullMQ/Kafka async workers) lives in the
282
+ ``attackmap-analyzer-node-service`` plugin and merges on top via
283
+ the existing merge pipeline. Keeping this analyzer thin means the
284
+ plugin has room to grow without stomping on core.
285
+ """
286
+
287
+ _JS_SUFFIXES = set(JS_TS_SUFFIXES)
288
+ metadata = AnalyzerMetadata(
289
+ name="javascript-web",
290
+ display_name="JavaScript / TypeScript Web Analyzer",
291
+ version="0.2.0",
292
+ description="Broad built-in analyzer for JavaScript and TypeScript web applications.",
293
+ scope="JavaScript/TypeScript source files (.js/.jsx/.mjs/.cjs/.ts/.tsx). Deep framework coverage lives in attackmap-analyzer-node-service; this is the fallback.",
294
+ targets=["express", "fastify", "koa", "node"],
295
+ languages=["javascript", "typescript"],
296
+ priority=20,
297
+ experimental=False,
298
+ enabled_by_default=True,
299
+ )
300
+
301
+ @property
302
+ def name(self) -> str:
303
+ return self.metadata.name
304
+
305
+ def detect(self, root: str | Path) -> bool:
306
+ """Return True when the repo looks JS/TS-shaped.
307
+
308
+ Signals (any one is sufficient):
309
+ - `package.json` at any depth,
310
+ - a JS/TS-family file at any depth (bounded by SKIP_DIRS).
311
+
312
+ No framework check: any JS/TS repo gets this fallback analyzer.
313
+ """
314
+ repo = Path(root)
315
+ if not repo.exists() or not repo.is_dir():
316
+ return False
317
+ try:
318
+ for candidate in walk_repo(repo, prune=is_skipped_dir):
319
+ if in_skipped_dir(candidate.relative_to(repo)):
320
+ continue
321
+ if candidate.name == "package.json":
322
+ return True
323
+ if candidate.suffix in self._JS_SUFFIXES:
324
+ return True
325
+ except OSError:
326
+ return False
327
+ return False
328
+
329
+ def analyze(
330
+ self, root: str | Path, progress: "ScanProgress | None" = None, recall: bool = False
331
+ ) -> AnalyzerResult:
332
+ return scan_files(root, suffixes=self._JS_SUFFIXES, progress=progress)
333
+
334
+
335
+ class BuiltinConfigAnalyzer:
336
+ """Built-in analyzer that walks YAML / TOML / JSON / INI / .env
337
+ config files and extracts DB URLs, service endpoints, and
338
+ secret-shaped literals (see #43).
339
+
340
+ Deep parsing lives elsewhere (docker-compose service graph in the
341
+ forthcoming attackmap-analyzer-iac plugin, per #40); this analyzer
342
+ covers the broad-strokes application-config case using the same
343
+ regex-based approach the source scanner uses.
344
+ """
345
+
346
+ metadata = AnalyzerMetadata(
347
+ name="config",
348
+ display_name="Config File Analyzer",
349
+ version="0.1.0",
350
+ description="Broad built-in analyzer for YAML / TOML / JSON / INI / .env application config files.",
351
+ scope="Application config files. Extracts DB connection strings, external service URLs, and secret-shaped literals; deep IaC parsing (docker-compose service graph, Dockerfile hardening) lives in attackmap-analyzer-iac.",
352
+ targets=["yaml", "toml", "json", "ini", "env"],
353
+ languages=[],
354
+ priority=30,
355
+ experimental=False,
356
+ enabled_by_default=True,
357
+ )
358
+
359
+ @property
360
+ def name(self) -> str:
361
+ return self.metadata.name
362
+
363
+ def detect(self, root: str | Path) -> bool:
364
+ from .config_scanner import should_scan_config_file
365
+ repo = Path(root)
366
+ if not repo.exists() or not repo.is_dir():
367
+ return False
368
+ try:
369
+ for candidate in walk_repo(repo, prune=is_skipped_dir):
370
+ if in_skipped_dir(candidate.relative_to(repo)):
371
+ continue
372
+ if should_scan_config_file(candidate):
373
+ return True
374
+ except OSError:
375
+ return False
376
+ return False
377
+
378
+ def analyze(self, root: str | Path) -> AnalyzerResult:
379
+ from .config_scanner import scan_config_repo
380
+ return scan_config_repo(root)
381
+
382
+
383
+ def get_builtin_repository_analyzers() -> list[Analyzer]:
384
+ return [
385
+ BuiltinPythonWebAnalyzer(),
386
+ BuiltinJavaScriptWebAnalyzer(),
387
+ BuiltinConfigAnalyzer(),
388
+ DefaultAnalyzer(),
389
+ ]
390
+
391
+
392
+ # Official plugins that only add to a built-in analyzer rather than replace it:
393
+ # selecting the plugin with `-m` also selects the built-in (#401).
394
+ # attackmap-analyzer-python leaves FastAPI / Flask routes to python-web.
395
+ ADDITIVE_OVER_BUILTIN: dict[str, str] = {"python": "python-web"}
396
+
397
+ _BUILTIN_ANALYZER_TYPES = (BuiltinPythonWebAnalyzer, BuiltinJavaScriptWebAnalyzer, BuiltinConfigAnalyzer, DefaultAnalyzer)
398
+
399
+
400
+ def is_builtin_analyzer(analyzer: object) -> bool:
401
+ """Whether ``analyzer`` is one of core's own extractors, by type: a
402
+ plugin can't claim it by name or by pre-stamping ``source_analyzer``.
403
+ A plugin route supersedes a built-in twin (#337)."""
404
+ return isinstance(analyzer, _BUILTIN_ANALYZER_TYPES)
405
+
406
+
407
+ # Official plugins by package name, pinned to immutable commits (#237).
408
+ _OFFICIAL_BY_PACKAGE = {entry["package"]: entry for entry in OFFICIAL_PLUGINS}
409
+ # ...and by each analyzer entry point a package registers (#190): one package
410
+ # may ship several analyzers (attackmap-analyzer-swift registers `swift` and
411
+ # `objc`), so `-m objc` must resolve to the swift package. Underscored entry
412
+ # point names (`java_spring`) also match their hyphenated analyzer names.
413
+ # ...and by GitHub repo name, so an official repo lists under its package's
414
+ # analyzer name even if the repo is named differently (#259).
415
+ _OFFICIAL_BY_REPO = {entry["repo"]: entry for entry in OFFICIAL_PLUGINS}
416
+ _OFFICIAL_BY_ENTRY_POINT = {
417
+ ep.replace("_", "-"): entry for entry in OFFICIAL_PLUGINS for ep in entry.get("entry_points", ())
418
+ }
419
+ TRUSTED_ONLY_ENV = "ATTACKMAP_TRUSTED_ANALYZERS_ONLY"
420
+ ALLOW_SYSTEM_INSTALL_ENV = "ATTACKMAP_ALLOW_SYSTEM_INSTALL"
421
+
422
+
423
+ class MissingAnalyzersError(ValueError):
424
+ """Requested official analyzers aren't installed (and weren't auto-installed)."""
425
+
426
+ def __init__(self, missing: list[str]) -> None:
427
+ self.missing = missing
428
+ super().__init__(f"Requested analyzer module(s) not available: {', '.join(missing)}")
429
+
430
+
431
+ def official_plugin(name: str) -> dict | None:
432
+ """The lock entry for an official analyzer (by analyzer, package or repo
433
+ name, or by any analyzer entry point the package registers)."""
434
+ package = _normalize_repo_name(name)
435
+ entry = _OFFICIAL_BY_PACKAGE.get(package)
436
+ if entry is None:
437
+ entry = _OFFICIAL_BY_ENTRY_POINT.get(package.removeprefix("attackmap-analyzer-").replace("_", "-"))
438
+ return entry
439
+
440
+
441
+ def trusted_analyzers_only() -> bool:
442
+ return os.environ.get(TRUSTED_ONLY_ENV, "").strip().lower() in {"1", "true", "yes"}
443
+
444
+
445
+ def _entry_point_distribution(analyzer_entry_point: object) -> str | None:
446
+ dist = getattr(analyzer_entry_point, "dist", None)
447
+ name = getattr(dist, "name", None) if dist is not None else None
448
+ return name.lower().replace("_", "-") if isinstance(name, str) else None
449
+
450
+
451
+ def discover_installed_analyzers(group: str = ANALYZER_ENTRYPOINT_GROUP) -> list[Analyzer]:
452
+ discovered: list[Analyzer] = []
453
+ all_entry_points = entry_points()
454
+ if hasattr(all_entry_points, "select"):
455
+ candidates = list(all_entry_points.select(group=group))
456
+ else:
457
+ candidates = list(all_entry_points.get(group, ()))
458
+
459
+ trusted_only = trusted_analyzers_only()
460
+ for analyzer_entry_point in sorted(candidates, key=lambda candidate: candidate.name):
461
+ # Any installed distribution can register an analyzer and run inside
462
+ # every scan (#237): flag ones that aren't official plugins, and skip
463
+ # them entirely in trusted-only mode (the GitHub Action's default).
464
+ distribution = _entry_point_distribution(analyzer_entry_point)
465
+ if distribution not in _OFFICIAL_BY_PACKAGE:
466
+ entry_name = getattr(analyzer_entry_point, "name", "<unknown>")
467
+ if trusted_only:
468
+ logger.warning(
469
+ "Skipping analyzer '%s' from '%s': not an official AttackMap plugin "
470
+ "(trusted-analyzers-only).", entry_name, distribution or "unknown distribution"
471
+ )
472
+ continue
473
+ logger.warning(
474
+ "Loading third-party analyzer '%s' from '%s' (not an official AttackMap plugin).",
475
+ entry_name, distribution or "unknown distribution",
476
+ )
477
+ analyzer = _load_discovered_analyzer(analyzer_entry_point)
478
+ if analyzer is None:
479
+ continue
480
+ discovered.append(analyzer)
481
+ return discovered
482
+
483
+
484
+ def _load_discovered_analyzer(analyzer_entry_point: object) -> Analyzer | None:
485
+ entry_name = getattr(analyzer_entry_point, "name", "<unknown>")
486
+ try:
487
+ loaded_object = analyzer_entry_point.load()
488
+ except Exception as exc:
489
+ logger.warning("Failed to load analyzer entry point '%s': %s", entry_name, exc)
490
+ return None
491
+
492
+ try:
493
+ analyzer = _coerce_analyzer_instance(loaded_object)
494
+ except Exception as exc:
495
+ logger.warning("Failed to initialize analyzer entry point '%s': %s", entry_name, exc)
496
+ return None
497
+
498
+ try:
499
+ canonical_metadata = normalize_analyzer_metadata(getattr(analyzer, "metadata", None))
500
+ except Exception as exc:
501
+ logger.warning("Failed to normalize analyzer metadata for '%s': %s", entry_name, exc)
502
+ return None
503
+ try:
504
+ setattr(analyzer, "metadata", canonical_metadata)
505
+ except Exception:
506
+ # Some analyzers may expose read-only metadata attributes; normalization is still validated.
507
+ pass
508
+
509
+ if not _is_valid_analyzer(analyzer):
510
+ logger.warning("Skipping entry point '%s': loaded object is not a valid analyzer.", entry_name)
511
+ return None
512
+ return analyzer
513
+
514
+
515
+ def _coerce_analyzer_instance(loaded_object: object) -> object:
516
+ if isinstance(loaded_object, type):
517
+ return loaded_object()
518
+ if hasattr(loaded_object, "analyze") and hasattr(loaded_object, "metadata"):
519
+ return loaded_object
520
+ if callable(loaded_object):
521
+ return loaded_object()
522
+ return loaded_object
523
+
524
+
525
+ def _is_valid_analyzer(candidate: object) -> bool:
526
+ if not hasattr(candidate, "metadata") or not hasattr(candidate, "analyze") or not callable(candidate.analyze):
527
+ return False
528
+
529
+ candidate_name = getattr(candidate, "name", None)
530
+ if not isinstance(candidate_name, str) or not candidate_name.strip():
531
+ return False
532
+
533
+ try:
534
+ metadata = normalize_analyzer_metadata(getattr(candidate, "metadata"))
535
+ except Exception:
536
+ return False
537
+ return isinstance(metadata.name, str) and bool(metadata.name.strip())
538
+
539
+
540
+ def get_registered_analyzers() -> list[Analyzer]:
541
+ analyzers = [*get_builtin_repository_analyzers(), *discover_installed_analyzers()]
542
+ deduplicated: list[Analyzer] = []
543
+ seen_names: set[str] = set()
544
+
545
+ for analyzer in analyzers:
546
+ if analyzer.name in seen_names:
547
+ logger.warning("Skipping duplicate analyzer name '%s'.", analyzer.name)
548
+ continue
549
+ seen_names.add(analyzer.name)
550
+ deduplicated.append(analyzer)
551
+
552
+ return deduplicated
553
+
554
+
555
+ def select_requested_analyzers(
556
+ requested_modules: Iterable[str],
557
+ *,
558
+ auto_install: bool = False,
559
+ installer: Callable[[str], None] | None = None,
560
+ ) -> list[Analyzer]:
561
+ requested_names = [_normalize_analyzer_name(module) for module in requested_modules if module.strip()]
562
+ if not requested_names:
563
+ return []
564
+
565
+ resolved = _match_requested_analyzers(requested_names)
566
+ missing_names = [name for name in requested_names if name not in resolved]
567
+
568
+ # Only official, lock-pinned plugins can ever be installed by name (#237):
569
+ # an unknown or mistyped name is an error, never a network install.
570
+ unofficial = [name for name in missing_names if official_plugin(name) is None]
571
+ if unofficial:
572
+ raise ValueError(
573
+ f"{', '.join(unofficial)}: not an official AttackMap analyzer and not installed. "
574
+ "If you trust a third-party analyzer, install it yourself (pip install <package>)."
575
+ )
576
+
577
+ if missing_names and auto_install:
578
+ install_fn = installer if installer is not None else install_analyzer_module
579
+ installed_packages: set[str] = set()
580
+ for missing_name in missing_names:
581
+ # One package can provide several requested analyzers (#190).
582
+ package = official_plugin(missing_name)["package"] # type: ignore[index]
583
+ if package in installed_packages:
584
+ continue
585
+ installed_packages.add(package)
586
+ try:
587
+ install_fn(package)
588
+ except Exception as exc:
589
+ raise ValueError(f"Failed to install analyzer module '{missing_name}': {exc}") from exc
590
+ resolved = _match_requested_analyzers(requested_names)
591
+ missing_names = [name for name in requested_names if name not in resolved]
592
+
593
+ if missing_names:
594
+ raise MissingAnalyzersError(missing_names)
595
+
596
+ selected: list[Analyzer] = []
597
+ seen: set[str] = set()
598
+ for name in requested_names:
599
+ if name in seen:
600
+ continue
601
+ seen.add(name)
602
+ selected.append(resolved[name])
603
+ # An official plugin that is additive over a built-in brings the built-in
604
+ # along: `-m python` alone would otherwise drop python-web, which extracts
605
+ # the FastAPI / Flask routes the plugin leaves to it (#401).
606
+ builtins = {analyzer.name: analyzer for analyzer in get_builtin_repository_analyzers()}
607
+ for name in list(seen):
608
+ companion = ADDITIVE_OVER_BUILTIN.get(name)
609
+ if companion is not None and companion not in seen and companion in builtins:
610
+ seen.add(companion)
611
+ selected.append(builtins[companion])
612
+ return selected
613
+
614
+
615
+ def analyzer_install_url(repo_name: str) -> str:
616
+ """The pip-installable URL of an official analyzer, pinned to the locked
617
+ commit (#237) — never a moving branch. Unknown names raise ValueError."""
618
+ entry = official_plugin(repo_name)
619
+ if entry is None:
620
+ raise ValueError(f"'{repo_name}' is not an official AttackMap analyzer")
621
+ return f"{entry['package']} @ git+{ANALYZER_ORG_BASE_URL}/{entry['repo']}.git@{entry['git_sha']}"
622
+
623
+
624
+ def analyzer_install_command(repo_name: str) -> list[str]:
625
+ """``python -I -m pip install <pinned url>``. ``-I`` (isolated mode) keeps
626
+ the current directory off ``sys.path`` and ignores ``PYTHON*`` variables
627
+ and the user site, so a ``pip/`` package committed in the checkout the
628
+ user is standing in cannot run in place of pip (GHSA-qv53-p57x-x9m4)."""
629
+ return [sys.executable, "-I", "-m", "pip", "install", analyzer_install_url(repo_name)]
630
+
631
+
632
+ def _install_env() -> dict[str, str]:
633
+ """The environment for the pip subprocess: the user's, minus every
634
+ ``PYTHON*`` variable (``PYTHONPATH``, ``PYTHONSTARTUP``, ...) and with
635
+ ``PYTHONSAFEPATH=1`` as a second guard beside ``-I``."""
636
+ env = {k: v for k, v in os.environ.items() if not k.upper().startswith("PYTHON")}
637
+ env["PYTHONSAFEPATH"] = "1"
638
+ return env
639
+
640
+
641
+ def install_analyzer_module(repo_name: str) -> None:
642
+ """pip-install one official analyzer at its pinned commit.
643
+
644
+ Refuses to install into a system (non-virtualenv) interpreter unless
645
+ ATTACKMAP_ALLOW_SYSTEM_INSTALL=1, and surfaces pip's own error output.
646
+ """
647
+ command = analyzer_install_command(repo_name)
648
+ in_venv = sys.prefix != sys.base_prefix
649
+ if not in_venv and os.environ.get(ALLOW_SYSTEM_INSTALL_ENV) != "1":
650
+ raise RuntimeError(
651
+ "refusing to pip-install into the system Python interpreter; use a virtualenv/pipx "
652
+ f"install of attackmap, or set {ALLOW_SYSTEM_INSTALL_ENV}=1"
653
+ )
654
+ logger.info("Installing analyzer module: %s", " ".join(command))
655
+ try:
656
+ # An empty working directory: pip never sees the scanned checkout,
657
+ # whatever the user's cwd is (GHSA-qv53-p57x-x9m4).
658
+ with tempfile.TemporaryDirectory(prefix="attackmap-pip-") as workdir:
659
+ subprocess.run(
660
+ command, check=True, capture_output=True, text=True, cwd=workdir, env=_install_env()
661
+ )
662
+ except subprocess.CalledProcessError as exc:
663
+ tail = "\n".join(((exc.stderr or "") + (exc.stdout or "")).strip().splitlines()[-15:])
664
+ raise RuntimeError(f"pip install failed ({' '.join(command[3:])}):\n{tail}") from exc
665
+ importlib.invalidate_caches()
666
+
667
+
668
+ def _match_requested_analyzers(requested_names: Iterable[str]) -> dict[str, Analyzer]:
669
+ available = {analyzer.name: analyzer for analyzer in get_registered_analyzers()}
670
+ return {name: available[name] for name in requested_names if name in available}
671
+
672
+
673
+ def _normalize_analyzer_name(module: str) -> str:
674
+ normalized = module.strip().lower().removesuffix(".git")
675
+ if normalized.startswith(ANALYZER_ORG_PREFIX):
676
+ normalized = normalized[len(ANALYZER_ORG_PREFIX) :]
677
+ if "/" in normalized:
678
+ normalized = normalized.rsplit("/", 1)[-1]
679
+ if normalized.startswith("attackmap-analyzer-"):
680
+ normalized = normalized.removeprefix("attackmap-analyzer-")
681
+ return normalized
682
+
683
+
684
+ def _normalize_repo_name(repo_name: str) -> str:
685
+ normalized = repo_name.strip().lower().removesuffix(".git")
686
+ if normalized.startswith(ANALYZER_ORG_PREFIX):
687
+ normalized = normalized[len(ANALYZER_ORG_PREFIX) :]
688
+ if "/" in normalized:
689
+ normalized = normalized.rsplit("/", 1)[-1]
690
+ if normalized.startswith("attackmap-analyzer-"):
691
+ return normalized
692
+ return f"attackmap-analyzer-{normalized}"
693
+
694
+
695
+ def get_analyzer_metadata(analyzer: Analyzer) -> AnalyzerMetadata:
696
+ return normalize_analyzer_metadata(analyzer.metadata)
697
+
698
+
699
+ def get_available_modules() -> list[AnalyzerMetadata]:
700
+ """Installed analyzers' metadata, in run order (#221)."""
701
+ return [get_analyzer_metadata(analyzer) for analyzer in sorted(get_registered_analyzers(), key=_run_order_key)]
702
+
703
+
704
+ def _run_order_key(analyzer: Analyzer) -> tuple[int, str]:
705
+ """Run (and merge-precedence) order: ``(metadata.priority, name)`` across
706
+ built-ins and plugins alike (#221). Merge is first-seen-wins, so a lower
707
+ priority also wins a duplicate signal."""
708
+ meta = getattr(analyzer, "metadata", None)
709
+ priority = getattr(meta, "priority", 100)
710
+ return (priority if isinstance(priority, int) else 100, analyzer.name)
711
+
712
+
713
+ def is_opt_in(analyzer: Analyzer) -> bool:
714
+ """``enabled_by_default=False`` analyzers run only when selected (#221)."""
715
+ meta = getattr(analyzer, "metadata", None)
716
+ return meta is not None and getattr(meta, "enabled_by_default", True) is False
717
+
718
+
719
+ def get_available_repository_modules(
720
+ *,
721
+ fetcher: Callable[[str], list[dict[str, object]]] | None = None,
722
+ ) -> list[AnalyzerRepositoryModule]:
723
+ fetch_fn = fetcher if fetcher is not None else _fetch_org_repositories
724
+ projects = fetch_fn(ANALYZER_ORG_API_URL)
725
+ modules: list[AnalyzerRepositoryModule] = []
726
+ for project in projects:
727
+ repo_name = str(project.get("name", "")).strip()
728
+ official = _OFFICIAL_BY_REPO.get(repo_name)
729
+ if official is not None:
730
+ # Named after its package, the name `-m` and the installer
731
+ # accept (#259).
732
+ analyzer_name = _normalize_analyzer_name(official["package"])
733
+ elif repo_name.startswith("attackmap-analyzer-"):
734
+ analyzer_name = _normalize_analyzer_name(repo_name)
735
+ else:
736
+ continue
737
+ web_url = str(project.get("html_url", f"{ANALYZER_ORG_BASE_URL}/{repo_name}")).strip()
738
+ modules.append(
739
+ AnalyzerRepositoryModule(
740
+ analyzer_name=analyzer_name,
741
+ repo_name=repo_name,
742
+ web_url=web_url,
743
+ )
744
+ )
745
+ modules.sort(key=lambda module: module.analyzer_name)
746
+ return modules
747
+
748
+
749
+ def _fetch_org_repositories(api_url: str) -> list[dict[str, object]]:
750
+ try:
751
+ with urlopen(api_url, timeout=10) as response:
752
+ payload = response.read().decode("utf-8")
753
+ except URLError as exc:
754
+ raise ValueError(f"Unable to reach analyzer module registry: {exc}") from exc
755
+ try:
756
+ decoded = json.loads(payload)
757
+ except json.JSONDecodeError as exc:
758
+ raise ValueError(f"Invalid analyzer module registry response: {exc}") from exc
759
+ if not isinstance(decoded, list):
760
+ raise ValueError("Unexpected analyzer module registry response shape.")
761
+ return [item for item in decoded if isinstance(item, dict)]
762
+
763
+
764
+ def analyze_repository(
765
+ root: str | Path,
766
+ analyzers: Iterable[Analyzer] | None = None,
767
+ progress: "ScanProgress | None" = None,
768
+ recall: bool = False,
769
+ strict: bool = False,
770
+ ) -> AnalyzerResult:
771
+ """Run every active analyzer and merge their results.
772
+
773
+ One analyzer failing (raising, or returning something that isn't a scan
774
+ result) no longer aborts the scan (#220): it is logged, recorded under
775
+ ``analyzer_errors`` and skipped. ``strict=True`` re-raises instead, for
776
+ plugin development and CI that wants fail-fast.
777
+ """
778
+ repo_root = Path(root).resolve()
779
+ active_analyzers = resolve_run_analyzers(repo_root, analyzers=analyzers)
780
+ results: list[AnalyzerResult] = []
781
+ builtin: list[bool] = [] # per result: produced by a core extractor
782
+ errors: list[AnalyzerError] = []
783
+ for analyzer in active_analyzers:
784
+ try:
785
+ result = _coerce_result(_call_analyze(analyzer, repo_root, progress, recall), repo_root)
786
+ except Exception as exc:
787
+ if strict:
788
+ raise
789
+ error = AnalyzerError(
790
+ analyzer=analyzer.name,
791
+ error_type=type(exc).__name__,
792
+ message=redact_text(str(exc))[:_ANALYZER_ERROR_MAX_CHARS],
793
+ )
794
+ errors.append(error)
795
+ logger.warning("Analyzer %r failed: %s: %s", analyzer.name, error.error_type, error.message)
796
+ if progress is not None:
797
+ progress.stage(f"Analyzer {analyzer.name} failed ({error.error_type}); continuing")
798
+ continue
799
+ for unknown in drop_unknown_kinds(result, analyzer.name):
800
+ if strict:
801
+ raise ValueError(unknown.message)
802
+ errors.append(unknown)
803
+ logger.warning("Analyzer %r: %s", analyzer.name, unknown.message)
804
+ _stamp_provenance(result, analyzer.name)
805
+ results.append(result)
806
+ builtin.append(is_builtin_analyzer(analyzer))
807
+ merged = (
808
+ merge_analyzer_results(results, root=repo_root, builtin=builtin)
809
+ if results
810
+ else AnalyzerResult(root=str(repo_root))
811
+ )
812
+ merged.analyzer_errors = errors
813
+ # Whole-repo passes run once, over every analyzer's merged signals (#219).
814
+ run_repo_passes(merged, repo_root, progress=progress, recall=recall)
815
+ if progress is not None:
816
+ progress.done()
817
+ return merged
818
+
819
+
820
+ _ANALYZER_ERROR_MAX_CHARS = 500
821
+
822
+
823
+ class AnalyzerResultError(TypeError):
824
+ """An analyzer returned something that isn't a scan result."""
825
+
826
+
827
+ def _coerce_result(result: object, root: Path) -> AnalyzerResult:
828
+ """Accept a ScanResult, validate a dict, reject anything else (#220)."""
829
+ if isinstance(result, AnalyzerResult):
830
+ return result
831
+ if isinstance(result, dict):
832
+ return AnalyzerResult.model_validate({"root": str(root), **result})
833
+ raise AnalyzerResultError(
834
+ f"analyze() returned {type(result).__name__}, expected ScanResult"
835
+ )
836
+
837
+
838
+ def _call_analyze(
839
+ analyzer: Analyzer,
840
+ root: Path,
841
+ progress: "ScanProgress | None",
842
+ recall: bool = False,
843
+ ) -> AnalyzerResult:
844
+ """Invoke ``analyzer.analyze``, forwarding ``progress``/``recall`` only to
845
+ analyzers that accept them. Keeps the plugin ``analyze(root)`` contract
846
+ intact — old plugins that don't know about these kwargs are called
847
+ unchanged, and ``recall`` is only ever passed when it's on."""
848
+ kwargs: dict[str, object] = {}
849
+ try:
850
+ params = inspect.signature(analyzer.analyze).parameters
851
+ if progress is not None and "progress" in params:
852
+ kwargs["progress"] = progress
853
+ if recall and "recall" in params:
854
+ kwargs["recall"] = recall
855
+ except (TypeError, ValueError):
856
+ pass
857
+ return analyzer.analyze(root, **kwargs) # type: ignore[call-arg]
858
+
859
+
860
+ # Signal lists whose `kind` is typed `str` so a plugin built against a newer
861
+ # core still validates: (ScanResult attribute, kinds this core knows, error
862
+ # type of the drop note[, field checked, default `kind`]). Records with
863
+ # another value are dropped one by one,
864
+ # the rest of the plugin's result is kept, and the drop is noted under
865
+ # `analyzer_errors` (`--strict-analyzers` raises instead).
866
+ KIND_VOCABULARIES: tuple[tuple[str, frozenset[str], str], ...] = (
867
+ ("cloud_misconfigs", frozenset(CLOUD_MISCONFIG_KINDS), "UnknownCloudMisconfigKind"),
868
+ ("app_surface_issues", frozenset(APP_SURFACE_ISSUE_KINDS), "UnknownAppSurfaceIssueKind"),
869
+ ("code_weaknesses", frozenset(CODE_WEAKNESS_KINDS), "UnknownCodeWeaknessKind"),
870
+ ("dependencies", frozenset(DEPENDENCY_ECOSYSTEMS), "UnknownDependencyEcosystem", "ecosystem"),
871
+ )
872
+
873
+
874
+ def drop_unknown_kinds(result: AnalyzerResult, analyzer_name: str) -> list[AnalyzerError]:
875
+ notes: list[AnalyzerError] = []
876
+ for attr, known, error_type, *rest in KIND_VOCABULARIES:
877
+ field = rest[0] if rest else "kind"
878
+ items = getattr(result, attr)
879
+ unknown = [item for item in items if getattr(item, field) not in known]
880
+ if not unknown:
881
+ continue
882
+ setattr(result, attr, [item for item in items if getattr(item, field) in known])
883
+ kinds = sorted({str(getattr(item, field)) for item in unknown})
884
+ message = (
885
+ f"dropped {len(unknown)} {attr} record(s) with {field}(s) this AttackMap doesn't know: "
886
+ f"{', '.join(kinds[:5])}{' …' if len(kinds) > 5 else ''} (upgrade attackmap to report them)"
887
+ )
888
+ notes.append(
889
+ AnalyzerError(
890
+ analyzer=analyzer_name, error_type=error_type,
891
+ message=redact_text(message)[:_ANALYZER_ERROR_MAX_CHARS],
892
+ )
893
+ )
894
+ return notes
895
+
896
+
897
+ def _stamp_provenance(result: AnalyzerResult, analyzer_name: str) -> None:
898
+ """Tag every signal in `result` with the analyzer that produced it.
899
+
900
+ Done in core rather than in each analyzer so the 13 published plugins
901
+ don't need to know about the field — they emit signals as they always
902
+ have, and core stamps provenance on the way to the merge. Only stamps
903
+ signals that don't already carry a source (plugins that want to attribute
904
+ a signal to something more specific can pre-populate the field).
905
+ """
906
+ for rule in MERGE_SCHEMA:
907
+ for item in getattr(result, rule.attr):
908
+ if item.source_analyzer is None:
909
+ item.source_analyzer = analyzer_name
910
+
911
+
912
+ def resolve_run_analyzers(
913
+ root: str | Path,
914
+ analyzers: Iterable[Analyzer] | None = None,
915
+ opt_in_matches: list[str] | None = None,
916
+ ) -> list[Analyzer]:
917
+ """The analyzers to run on ``root``, in ``(priority, name)`` order (#221).
918
+
919
+ With ``analyzers=None`` (no ``--module``), every registered analyzer whose
920
+ ``detect()`` passes runs, except opt-in ones (``enabled_by_default=False``).
921
+ Those are skipped, and their names appended to ``opt_in_matches`` when
922
+ given, so the CLI can say they matched. An explicit ``analyzers`` list (the
923
+ ``--module`` selection) always runs, subject to ``detect()``.
924
+ """
925
+ repo_root = Path(root).resolve()
926
+ explicit = analyzers is not None
927
+ registered = sorted(list(analyzers) if explicit else get_registered_analyzers(), key=_run_order_key)
928
+ selected: list[Analyzer] = []
929
+ for analyzer in registered:
930
+ if not _should_run_analyzer(analyzer, repo_root):
931
+ continue
932
+ if not explicit and is_opt_in(analyzer):
933
+ if opt_in_matches is not None:
934
+ opt_in_matches.append(analyzer.name)
935
+ continue
936
+ selected.append(analyzer)
937
+ return selected
938
+
939
+
940
+ def _should_run_analyzer(analyzer: Analyzer, repo_root: Path) -> bool:
941
+ detect_fn = getattr(analyzer, "detect", None)
942
+ if detect_fn is None:
943
+ return True
944
+ if not callable(detect_fn):
945
+ return True
946
+ try:
947
+ return bool(detect_fn(repo_root))
948
+ except Exception as exc:
949
+ logger.warning("Analyzer '%s' detect() failed: %s", analyzer.name, exc)
950
+ return False
951
+
952
+
953
+ def merge_analyzer_results(
954
+ results: Iterable[AnalyzerResult],
955
+ root: str | Path | None = None,
956
+ builtin: Iterable[bool] | None = None,
957
+ ) -> AnalyzerResult:
958
+ """Combine multiple whole-repo analyzer results into one.
959
+
960
+ Field-by-field merge behavior is declared in :data:`merge.MERGE_SCHEMA`
961
+ and applied uniformly here. See `merge.py` for the rules. ``builtin``
962
+ marks, per result, those produced by a core extractor
963
+ (:func:`is_builtin_analyzer`); a plugin route then supersedes a built-in
964
+ twin of it (#337). Without it, routes merge on their key alone.
965
+ """
966
+ result_list = list(results)
967
+ if not result_list:
968
+ resolved_root = Path(root).resolve() if root is not None else Path(".").resolve()
969
+ return AnalyzerResult(root=str(resolved_root))
970
+
971
+ resolved_root = Path(root).resolve() if root is not None else Path(result_list[0].root).resolve()
972
+ merged = AnalyzerResult(root=str(resolved_root))
973
+ seen_by_attr: dict[str, set] = {rule.attr: set() for rule in MERGE_SCHEMA}
974
+
975
+ # A plugin route on the same (file, line) replaces core's built-in twin of
976
+ # it (a group prefix the built-in couldn't join), whatever order they run in.
977
+ flags = list(builtin) if builtin is not None else [False] * len(result_list)
978
+ if len(flags) != len(result_list):
979
+ raise ValueError("builtin must have one flag per result")
980
+ superseded = (
981
+ superseded_builtin_routes(
982
+ (route for result, flag in zip(result_list, flags) if flag for route in result.routes),
983
+ (route for result, flag in zip(result_list, flags) if not flag for route in result.routes),
984
+ )
985
+ if any(flags) and not all(flags)
986
+ else set()
987
+ )
988
+ for result in result_list:
989
+ merged.files_scanned += result.files_scanned
990
+ for language in result.languages:
991
+ if language not in merged.languages:
992
+ merged.languages.append(language)
993
+ for limitation in result.limitations:
994
+ if limitation not in merged.limitations:
995
+ merged.limitations.append(limitation)
996
+ for rule in MERGE_SCHEMA:
997
+ items = getattr(result, rule.attr)
998
+ if rule.attr == "routes" and superseded:
999
+ items = [route for route in items if id(route) not in superseded]
1000
+ merge_into(merged, items, rule, seen_by_attr[rule.attr])
1001
+
1002
+ merged.languages.sort()
1003
+ return merged