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