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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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
|