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/suggest.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Plugin recommendation UX (#46).
|
|
2
|
+
|
|
3
|
+
Walks a repository lightly, spots ecosystem markers (manifest files,
|
|
4
|
+
extensions, config directories), and maps each to the AttackMap plugin
|
|
5
|
+
that would give the deepest signal for it.
|
|
6
|
+
|
|
7
|
+
The output is a ranked list of pip install lines. Ranking is by matched
|
|
8
|
+
signal count (more matches = stronger recommendation) with plugin name
|
|
9
|
+
as tiebreaker for stable output.
|
|
10
|
+
|
|
11
|
+
Detector philosophy:
|
|
12
|
+
- Prefer manifest files (``pyproject.toml``, ``go.mod``, ``Cargo.toml``,
|
|
13
|
+
``composer.json``) over raw file globs — a directory full of ``*.py``
|
|
14
|
+
inside a Rails project shouldn't recommend the Python analyzer.
|
|
15
|
+
- Cap directory walk depth so a monorepo doesn't cost seconds.
|
|
16
|
+
- Never touch the network. Never introspect the file content beyond
|
|
17
|
+
the small ``composer.json`` / ``package.json`` peek that decides
|
|
18
|
+
between generic and framework-specific plugins.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import shlex
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .safe_fs import is_unsafe_link
|
|
29
|
+
from .analyzers import analyzer_install_url, get_available_modules
|
|
30
|
+
|
|
31
|
+
# Depth cap for the recursive walk. Enough for a monorepo's top-level
|
|
32
|
+
# packages/services/apps folders; not enough to explore node_modules.
|
|
33
|
+
_MAX_DEPTH = 3
|
|
34
|
+
|
|
35
|
+
_SKIP_DIRS = {
|
|
36
|
+
".git",
|
|
37
|
+
"node_modules",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
"dist",
|
|
40
|
+
"build",
|
|
41
|
+
".venv",
|
|
42
|
+
"venv",
|
|
43
|
+
"target",
|
|
44
|
+
"vendor",
|
|
45
|
+
".idea",
|
|
46
|
+
".vscode",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".pytest_cache",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Suggestion:
|
|
54
|
+
"""One plugin recommendation for the repo."""
|
|
55
|
+
|
|
56
|
+
plugin: str # Package name, e.g. "attackmap-analyzer-python"
|
|
57
|
+
analyzer_name: str # Analyzer entry-point name, e.g. "python"
|
|
58
|
+
matched_signals: tuple[str, ...]
|
|
59
|
+
installed: bool = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def pip_install(self) -> str:
|
|
63
|
+
return shlex.join(["pip", "install", analyzer_install_url(self.plugin)])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class _Detector:
|
|
68
|
+
plugin: str
|
|
69
|
+
analyzer_name: str
|
|
70
|
+
manifest_files: tuple[str, ...] = ()
|
|
71
|
+
extensions: tuple[str, ...] = ()
|
|
72
|
+
directory_names: tuple[str, ...] = ()
|
|
73
|
+
# A callable that receives (root, manifest_paths_by_name, extension_hits)
|
|
74
|
+
# and returns extra matched signals or an empty list. Used for framework
|
|
75
|
+
# discrimination (Spring vs generic Java, Laminas vs generic PHP).
|
|
76
|
+
extra: object = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spring_extras(
|
|
80
|
+
root: Path,
|
|
81
|
+
manifests: dict[str, list[Path]],
|
|
82
|
+
_exts: dict[str, list[Path]],
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
for name in ("pom.xml", "build.gradle", "build.gradle.kts"):
|
|
85
|
+
for p in manifests.get(name, ()):
|
|
86
|
+
try:
|
|
87
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
if "org.springframework" in text or "spring-boot" in text:
|
|
91
|
+
return [f"{name} references Spring"]
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Manifest names that extras callbacks read even though no detector's
|
|
96
|
+
# `manifest_files` demands the whole-file trigger. `_sweep` still collects
|
|
97
|
+
# them so ``manifests.get(name)`` in the callbacks returns hits.
|
|
98
|
+
_EXTRAS_MANIFESTS = ("pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "package.json")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _laminas_extras(
|
|
102
|
+
root: Path,
|
|
103
|
+
manifests: dict[str, list[Path]],
|
|
104
|
+
_exts: dict[str, list[Path]],
|
|
105
|
+
) -> list[str]:
|
|
106
|
+
for p in manifests.get("composer.json", ()):
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(p.read_text(encoding="utf-8", errors="ignore"))
|
|
109
|
+
except (OSError, json.JSONDecodeError):
|
|
110
|
+
continue
|
|
111
|
+
deps: dict = {}
|
|
112
|
+
for key in ("require", "require-dev"):
|
|
113
|
+
section = data.get(key) or {}
|
|
114
|
+
if isinstance(section, dict):
|
|
115
|
+
deps.update(section)
|
|
116
|
+
if any(k.startswith("laminas/") or k.startswith("zendframework/") for k in deps):
|
|
117
|
+
return ["composer.json depends on laminas/*"]
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _omeka_extras(
|
|
122
|
+
root: Path,
|
|
123
|
+
manifests: dict[str, list[Path]],
|
|
124
|
+
_exts: dict[str, list[Path]],
|
|
125
|
+
) -> list[str]:
|
|
126
|
+
for p in manifests.get("composer.json", ()):
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(p.read_text(encoding="utf-8", errors="ignore"))
|
|
129
|
+
except (OSError, json.JSONDecodeError):
|
|
130
|
+
continue
|
|
131
|
+
deps: dict = {}
|
|
132
|
+
for key in ("require", "require-dev"):
|
|
133
|
+
section = data.get(key) or {}
|
|
134
|
+
if isinstance(section, dict):
|
|
135
|
+
deps.update(section)
|
|
136
|
+
if any(k.startswith("omeka/") for k in deps):
|
|
137
|
+
return ["composer.json depends on omeka/*"]
|
|
138
|
+
# Fallback structural cue: an Omeka-S module tends to ship
|
|
139
|
+
# `config/module.config.php` alongside `Module.php`.
|
|
140
|
+
if (root / "config" / "module.config.php").exists() and (root / "Module.php").exists():
|
|
141
|
+
return ["Omeka-S module layout (config/module.config.php + Module.php)"]
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _atproto_extras(
|
|
146
|
+
root: Path,
|
|
147
|
+
manifests: dict[str, list[Path]],
|
|
148
|
+
_exts: dict[str, list[Path]],
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
hits: list[str] = []
|
|
151
|
+
for p in manifests.get("package.json", ()):
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(p.read_text(encoding="utf-8", errors="ignore"))
|
|
154
|
+
except (OSError, json.JSONDecodeError):
|
|
155
|
+
continue
|
|
156
|
+
deps: dict = {}
|
|
157
|
+
for key in ("dependencies", "devDependencies"):
|
|
158
|
+
section = data.get(key) or {}
|
|
159
|
+
if isinstance(section, dict):
|
|
160
|
+
deps.update(section)
|
|
161
|
+
if any(k.startswith("@atproto/") for k in deps):
|
|
162
|
+
hits.append("package.json depends on @atproto/*")
|
|
163
|
+
break
|
|
164
|
+
if (root / "lexicons").is_dir():
|
|
165
|
+
hits.append("lexicons/ directory present")
|
|
166
|
+
return hits
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _php_web_extras(
|
|
170
|
+
_root: Path,
|
|
171
|
+
manifests: dict[str, list[Path]],
|
|
172
|
+
_exts: dict[str, list[Path]],
|
|
173
|
+
) -> list[str]:
|
|
174
|
+
# Only recommend the generic PHP-web plugin when we didn't already
|
|
175
|
+
# see a framework marker — the caller strips this hit in that case.
|
|
176
|
+
if manifests.get("composer.json"):
|
|
177
|
+
return ["composer.json present (generic PHP web app)"]
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
DETECTORS: tuple[_Detector, ...] = (
|
|
182
|
+
_Detector(
|
|
183
|
+
plugin="attackmap-analyzer-python",
|
|
184
|
+
analyzer_name="python",
|
|
185
|
+
manifest_files=("pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"),
|
|
186
|
+
extensions=(".py",),
|
|
187
|
+
),
|
|
188
|
+
_Detector(
|
|
189
|
+
plugin="attackmap-analyzer-node-service",
|
|
190
|
+
analyzer_name="node-service",
|
|
191
|
+
manifest_files=("package.json", "tsconfig.json", "pnpm-lock.yaml", "yarn.lock"),
|
|
192
|
+
extensions=(".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"),
|
|
193
|
+
),
|
|
194
|
+
_Detector(
|
|
195
|
+
plugin="attackmap-analyzer-atproto",
|
|
196
|
+
analyzer_name="atproto",
|
|
197
|
+
extra=_atproto_extras,
|
|
198
|
+
),
|
|
199
|
+
_Detector(
|
|
200
|
+
plugin="attackmap-analyzer-go",
|
|
201
|
+
analyzer_name="go",
|
|
202
|
+
manifest_files=("go.mod", "go.sum"),
|
|
203
|
+
extensions=(".go",),
|
|
204
|
+
),
|
|
205
|
+
_Detector(
|
|
206
|
+
plugin="attackmap-analyzer-rust",
|
|
207
|
+
analyzer_name="rust",
|
|
208
|
+
manifest_files=("Cargo.toml", "Cargo.lock"),
|
|
209
|
+
extensions=(".rs",),
|
|
210
|
+
),
|
|
211
|
+
_Detector(
|
|
212
|
+
plugin="attackmap-analyzer-java-spring",
|
|
213
|
+
analyzer_name="java-spring",
|
|
214
|
+
# No manifest_files here on purpose: a plain Maven pom.xml
|
|
215
|
+
# without a Spring dependency should NOT recommend the Spring
|
|
216
|
+
# analyzer. The extras callback opens the manifests and only
|
|
217
|
+
# returns a signal when it finds an actual Spring reference.
|
|
218
|
+
extra=_spring_extras,
|
|
219
|
+
),
|
|
220
|
+
_Detector(
|
|
221
|
+
plugin="attackmap-analyzer-dotnet",
|
|
222
|
+
analyzer_name="dotnet",
|
|
223
|
+
manifest_files=("Directory.Build.props", "global.json"),
|
|
224
|
+
extensions=(".cs", ".csproj", ".sln"),
|
|
225
|
+
),
|
|
226
|
+
_Detector(
|
|
227
|
+
plugin="attackmap-analyzer-terraform",
|
|
228
|
+
analyzer_name="terraform",
|
|
229
|
+
extensions=(".tf", ".tfvars"),
|
|
230
|
+
),
|
|
231
|
+
_Detector(
|
|
232
|
+
plugin="attackmap-analyzer-c",
|
|
233
|
+
analyzer_name="c",
|
|
234
|
+
manifest_files=("configure.ac", "Makefile.am", "Makefile"),
|
|
235
|
+
extensions=(".c", ".h"),
|
|
236
|
+
),
|
|
237
|
+
_Detector(
|
|
238
|
+
plugin="attackmap-analyzer-cpp",
|
|
239
|
+
analyzer_name="cpp",
|
|
240
|
+
manifest_files=("CMakeLists.txt",),
|
|
241
|
+
extensions=(".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"),
|
|
242
|
+
),
|
|
243
|
+
_Detector(
|
|
244
|
+
plugin="attackmap-analyzer-swift",
|
|
245
|
+
analyzer_name="swift",
|
|
246
|
+
manifest_files=("Package.swift", "Package.resolved"),
|
|
247
|
+
extensions=(".swift",),
|
|
248
|
+
),
|
|
249
|
+
_Detector(
|
|
250
|
+
plugin="attackmap-analyzer-php-laminas",
|
|
251
|
+
analyzer_name="php-laminas",
|
|
252
|
+
extra=_laminas_extras,
|
|
253
|
+
),
|
|
254
|
+
_Detector(
|
|
255
|
+
plugin="attackmap-analyzer-omeka-s",
|
|
256
|
+
analyzer_name="omeka-s",
|
|
257
|
+
extra=_omeka_extras,
|
|
258
|
+
),
|
|
259
|
+
_Detector(
|
|
260
|
+
plugin="attackmap-analyzer-php-web",
|
|
261
|
+
analyzer_name="php-web",
|
|
262
|
+
extra=_php_web_extras,
|
|
263
|
+
extensions=(".php",),
|
|
264
|
+
),
|
|
265
|
+
_Detector(
|
|
266
|
+
plugin="attackmap-analyzer-iac",
|
|
267
|
+
analyzer_name="iac",
|
|
268
|
+
manifest_files=(
|
|
269
|
+
"Dockerfile",
|
|
270
|
+
"docker-compose.yml",
|
|
271
|
+
"docker-compose.yaml",
|
|
272
|
+
"compose.yml",
|
|
273
|
+
"compose.yaml",
|
|
274
|
+
),
|
|
275
|
+
directory_names=(".github/workflows",),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def detect_ecosystems(root: str | Path) -> list[Suggestion]:
|
|
281
|
+
"""Walk ``root`` shallowly, return a ranked list of plugin suggestions."""
|
|
282
|
+
root_path = Path(root).resolve()
|
|
283
|
+
if not root_path.exists() or not root_path.is_dir():
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
manifests, extensions_hit, directories_hit = _sweep(root_path)
|
|
287
|
+
installed_names = _installed_analyzer_names()
|
|
288
|
+
|
|
289
|
+
suggestions: list[Suggestion] = []
|
|
290
|
+
php_framework_seen = False
|
|
291
|
+
|
|
292
|
+
for detector in DETECTORS:
|
|
293
|
+
signals: list[str] = []
|
|
294
|
+
for name in detector.manifest_files:
|
|
295
|
+
if manifests.get(name):
|
|
296
|
+
first = manifests[name][0].relative_to(root_path)
|
|
297
|
+
signals.append(str(first))
|
|
298
|
+
for ext in detector.extensions:
|
|
299
|
+
if extensions_hit.get(ext):
|
|
300
|
+
sample = extensions_hit[ext][0].relative_to(root_path)
|
|
301
|
+
signals.append(f"{ext} sources (e.g. {sample})")
|
|
302
|
+
for dir_name in detector.directory_names:
|
|
303
|
+
if dir_name in directories_hit:
|
|
304
|
+
signals.append(f"{dir_name}/ present")
|
|
305
|
+
if callable(detector.extra):
|
|
306
|
+
signals.extend(detector.extra(root_path, manifests, extensions_hit))
|
|
307
|
+
|
|
308
|
+
# Framework-specific PHP hits (laminas, omeka-s) suppress the
|
|
309
|
+
# generic PHP-web recommendation — otherwise a Laminas project
|
|
310
|
+
# ends up with both.
|
|
311
|
+
if detector.plugin in {"attackmap-analyzer-php-laminas", "attackmap-analyzer-omeka-s"} and signals:
|
|
312
|
+
php_framework_seen = True
|
|
313
|
+
if detector.plugin == "attackmap-analyzer-php-web" and php_framework_seen:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if not signals:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
suggestions.append(
|
|
320
|
+
Suggestion(
|
|
321
|
+
plugin=detector.plugin,
|
|
322
|
+
analyzer_name=detector.analyzer_name,
|
|
323
|
+
matched_signals=tuple(signals),
|
|
324
|
+
installed=detector.analyzer_name in installed_names,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
suggestions.sort(key=lambda s: (-len(s.matched_signals), s.plugin))
|
|
329
|
+
return suggestions
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _sweep(
|
|
333
|
+
root: Path,
|
|
334
|
+
) -> tuple[dict[str, list[Path]], dict[str, list[Path]], set[str]]:
|
|
335
|
+
manifests: dict[str, list[Path]] = {}
|
|
336
|
+
extensions: dict[str, list[Path]] = {}
|
|
337
|
+
directories: set[str] = set()
|
|
338
|
+
|
|
339
|
+
interesting_manifests = {
|
|
340
|
+
name for detector in DETECTORS for name in detector.manifest_files
|
|
341
|
+
}
|
|
342
|
+
interesting_manifests.update(_EXTRAS_MANIFESTS)
|
|
343
|
+
interesting_directories = {
|
|
344
|
+
name for detector in DETECTORS for name in detector.directory_names
|
|
345
|
+
}
|
|
346
|
+
interesting_exts = {ext for detector in DETECTORS for ext in detector.extensions}
|
|
347
|
+
|
|
348
|
+
# Note: `.github/workflows` is a two-segment name; check it explicitly.
|
|
349
|
+
if (root / ".github" / "workflows").is_dir():
|
|
350
|
+
directories.add(".github/workflows")
|
|
351
|
+
|
|
352
|
+
for path in _iter_paths(root):
|
|
353
|
+
if path.is_dir():
|
|
354
|
+
rel = path.relative_to(root).as_posix()
|
|
355
|
+
if rel in interesting_directories:
|
|
356
|
+
directories.add(rel)
|
|
357
|
+
continue
|
|
358
|
+
if path.name in interesting_manifests:
|
|
359
|
+
manifests.setdefault(path.name, []).append(path)
|
|
360
|
+
continue
|
|
361
|
+
suffix = path.suffix
|
|
362
|
+
if suffix in interesting_exts:
|
|
363
|
+
extensions.setdefault(suffix, []).append(path)
|
|
364
|
+
|
|
365
|
+
return manifests, extensions, directories
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _iter_paths(root: Path):
|
|
369
|
+
"""Depth-capped, dot-skipping traversal used by the sweep."""
|
|
370
|
+
|
|
371
|
+
def walk(directory: Path, depth: int):
|
|
372
|
+
try:
|
|
373
|
+
entries = sorted(directory.iterdir())
|
|
374
|
+
except (OSError, PermissionError):
|
|
375
|
+
return
|
|
376
|
+
for entry in entries:
|
|
377
|
+
name = entry.name
|
|
378
|
+
if name in _SKIP_DIRS or is_unsafe_link(root, entry):
|
|
379
|
+
continue
|
|
380
|
+
if entry.is_dir():
|
|
381
|
+
yield entry
|
|
382
|
+
if depth < _MAX_DEPTH:
|
|
383
|
+
yield from walk(entry, depth + 1)
|
|
384
|
+
else:
|
|
385
|
+
yield entry
|
|
386
|
+
|
|
387
|
+
yield from walk(root, 1)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _installed_analyzer_names() -> set[str]:
|
|
391
|
+
try:
|
|
392
|
+
return {module.name for module in get_available_modules()}
|
|
393
|
+
except Exception: # pragma: no cover — never let CLI ergonomics crash on registry failure
|
|
394
|
+
return set()
|