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/sbom.py ADDED
@@ -0,0 +1,418 @@
1
+ """SBOM inventory (slice 1 of #48).
2
+
3
+ Scans a repository for dependency manifests and emits a
4
+ ``DependencyHint`` per declared third-party package. Direct dependencies
5
+ only — no lockfile resolution, no transitive walk. Version ranges are
6
+ kept verbatim (``^4.16.0``, ``>=2,<3``, etc.); consumers that need a
7
+ resolved version look at lockfiles (deferred slice).
8
+
9
+ Supported ecosystems:
10
+ - pypi pyproject.toml (PEP 621) + requirements.txt
11
+ - npm package.json (dependencies + devDependencies)
12
+ - go go.mod (require blocks)
13
+ - cargo Cargo.toml ([dependencies] + [dev-dependencies])
14
+ - composer composer.json (require + require-dev)
15
+
16
+ CVE lookup is intentionally NOT here — it's a separate ticket (see the
17
+ follow-up filed with the PR that lands this slice) because it requires
18
+ network I/O, on-disk caching, and rate limiting.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import tomllib
26
+ from pathlib import Path
27
+
28
+ from .safe_fs import is_unsafe_link
29
+ from .lockfiles import parse_lockfiles
30
+ from .models import DependencyHint
31
+ from .srcpaths import split_lines
32
+
33
+ _MAX_DEPTH = 3
34
+ _SKIP_DIRS = {
35
+ ".git",
36
+ "node_modules",
37
+ "__pycache__",
38
+ "dist",
39
+ "build",
40
+ ".venv",
41
+ "venv",
42
+ "target",
43
+ "vendor",
44
+ }
45
+
46
+
47
+ def analyze_sbom(root: str | Path) -> list[DependencyHint]:
48
+ """Walk ``root`` and return a deduped list of DependencyHint records.
49
+
50
+ Manifests contribute direct deps with verbatim version ranges. Lockfiles
51
+ (#143) contribute exact resolved versions and the transitive tree; where a
52
+ lockfile supersedes a range-only manifest (npm/pypi/cargo/composer) the manifest
53
+ hints for that ecosystem are dropped in favour of the exact resolution.
54
+ """
55
+ root_path = Path(root).resolve()
56
+ if not root_path.exists() or not root_path.is_dir():
57
+ return []
58
+
59
+ manifest_hints: list[DependencyHint] = []
60
+ for manifest in _iter_manifests(root_path):
61
+ rel = str(manifest.relative_to(root_path)).replace("\\", "/")
62
+ try:
63
+ manifest_hints.extend(_parse_manifest(manifest, rel))
64
+ except (OSError, UnicodeDecodeError, ValueError, tomllib.TOMLDecodeError, json.JSONDecodeError):
65
+ # A malformed manifest shouldn't sink the whole scan; skip it.
66
+ continue
67
+
68
+ lock_hints, superseded = parse_lockfiles(root_path)
69
+
70
+ # Drop a range-only manifest hint only when a lockfile of the same
71
+ # ecosystem sits in the *same directory* — so a monorepo's lockfile in
72
+ # service A doesn't silently exclude service B's un-locked manifest (#143).
73
+ def _dir(rel: str) -> str:
74
+ return str(Path(rel).parent).replace("\\", "/")
75
+
76
+ kept_manifest = [
77
+ h for h in manifest_hints if (h.ecosystem, _dir(h.file)) not in superseded
78
+ ]
79
+
80
+ hints = kept_manifest + lock_hints
81
+ for hint in hints:
82
+ hint.source_analyzer = "sbom"
83
+
84
+ return _dedup(hints)
85
+
86
+
87
+ def _iter_manifests(root: Path):
88
+ """Depth-capped walk yielding candidate manifest files by name."""
89
+
90
+ interesting = {
91
+ "pyproject.toml",
92
+ "requirements.txt",
93
+ "package.json",
94
+ "go.mod",
95
+ "Cargo.toml",
96
+ "composer.json",
97
+ }
98
+
99
+ def walk(directory: Path, depth: int):
100
+ try:
101
+ entries = sorted(directory.iterdir())
102
+ except (OSError, PermissionError):
103
+ return
104
+ for entry in entries:
105
+ if entry.name in _SKIP_DIRS or is_unsafe_link(root, entry):
106
+ continue
107
+ if entry.is_dir():
108
+ if depth < _MAX_DEPTH:
109
+ yield from walk(entry, depth + 1)
110
+ elif entry.name in interesting:
111
+ yield entry
112
+
113
+ yield from walk(root, 1)
114
+
115
+
116
+ def _parse_manifest(path: Path, rel: str) -> list[DependencyHint]:
117
+ name = path.name
118
+ if name == "pyproject.toml":
119
+ return _parse_pyproject(path, rel)
120
+ if name == "requirements.txt":
121
+ return _parse_requirements(path, rel)
122
+ if name == "package.json":
123
+ return _parse_package_json(path, rel)
124
+ if name == "go.mod":
125
+ return _parse_go_mod(path, rel)
126
+ if name == "Cargo.toml":
127
+ return _parse_cargo_toml(path, rel)
128
+ if name == "composer.json":
129
+ return _parse_composer_json(path, rel)
130
+ return []
131
+
132
+
133
+ # ---------- Python ---------------------------------------------------------
134
+
135
+
136
+ _REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
137
+
138
+
139
+ def _parse_pyproject(path: Path, rel: str) -> list[DependencyHint]:
140
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
141
+ project = data.get("project") or {}
142
+ hints: list[DependencyHint] = []
143
+ for spec in project.get("dependencies") or []:
144
+ parsed = _parse_pep508(str(spec))
145
+ if parsed:
146
+ name, version = parsed
147
+ hints.append(DependencyHint(name=name, version=version, ecosystem="pypi", file=rel, evidence_text=str(spec)))
148
+ optional: dict = project.get("optional-dependencies") or {}
149
+ for group_name, specs in optional.items():
150
+ is_dev = group_name.lower() in {"dev", "test", "tests", "lint", "docs"}
151
+ for spec in specs or []:
152
+ parsed = _parse_pep508(str(spec))
153
+ if parsed:
154
+ name, version = parsed
155
+ hints.append(
156
+ DependencyHint(
157
+ name=name,
158
+ version=version,
159
+ ecosystem="pypi",
160
+ file=rel,
161
+ dev=is_dev,
162
+ evidence_text=f"[{group_name}] {spec}",
163
+ )
164
+ )
165
+ # Poetry (still fairly common); tool.poetry.dependencies is a dict.
166
+ poetry = (data.get("tool") or {}).get("poetry") or {}
167
+ for spec_name, spec_value in (poetry.get("dependencies") or {}).items():
168
+ if spec_name.lower() == "python":
169
+ continue
170
+ version = _stringify_poetry_version(spec_value)
171
+ hints.append(
172
+ DependencyHint(
173
+ name=spec_name,
174
+ version=version,
175
+ ecosystem="pypi",
176
+ file=rel,
177
+ evidence_text=f"tool.poetry.dependencies.{spec_name} = {spec_value!r}",
178
+ )
179
+ )
180
+ for spec_name, spec_value in (poetry.get("dev-dependencies") or {}).items():
181
+ version = _stringify_poetry_version(spec_value)
182
+ hints.append(
183
+ DependencyHint(
184
+ name=spec_name,
185
+ version=version,
186
+ ecosystem="pypi",
187
+ file=rel,
188
+ dev=True,
189
+ evidence_text=f"tool.poetry.dev-dependencies.{spec_name} = {spec_value!r}",
190
+ )
191
+ )
192
+ return hints
193
+
194
+
195
+ def _parse_pep508(spec: str) -> tuple[str, str] | None:
196
+ """PEP 508 spec → (name, version-or-marker-part)."""
197
+ stripped = spec.strip()
198
+ if not stripped or stripped.startswith("#"):
199
+ return None
200
+ match = _REQUIREMENT_NAME_RE.match(stripped)
201
+ if not match:
202
+ return None
203
+ name = match.group(1)
204
+ remainder = stripped[match.end():].strip()
205
+ # Cut off env markers (`; python_version < '3.10'`) — they aren't a version.
206
+ if ";" in remainder:
207
+ remainder = remainder.split(";", 1)[0].strip()
208
+ # Extras: `foo[bar]`
209
+ if remainder.startswith("["):
210
+ close = remainder.find("]")
211
+ if close != -1:
212
+ remainder = remainder[close + 1 :].strip()
213
+ version = remainder if remainder else "*"
214
+ return name, version
215
+
216
+
217
+ def _stringify_poetry_version(value: object) -> str:
218
+ if isinstance(value, str):
219
+ return value
220
+ if isinstance(value, dict):
221
+ return str(value.get("version") or "*")
222
+ return "*"
223
+
224
+
225
+ def _parse_requirements(path: Path, rel: str) -> list[DependencyHint]:
226
+ hints: list[DependencyHint] = []
227
+ for lineno, raw in enumerate(split_lines(path.read_text(encoding="utf-8")), start=1):
228
+ line = raw.split("#", 1)[0].strip()
229
+ if not line:
230
+ continue
231
+ if line.startswith("-"):
232
+ # -r / -e / -c / --index-url etc. — skip; not a dep declaration.
233
+ continue
234
+ if "://" in line.split(" @ ", 1)[0]:
235
+ # A bare VCS/archive URL (`git+https://…`) isn't a `name spec`
236
+ # line; parsing it would invent a dependency called "git" (#247).
237
+ continue
238
+ parsed = _parse_pep508(line)
239
+ if parsed:
240
+ name, version = parsed
241
+ hints.append(
242
+ DependencyHint(
243
+ name=name,
244
+ version=version,
245
+ ecosystem="pypi",
246
+ file=rel,
247
+ line=lineno,
248
+ evidence_text=line,
249
+ )
250
+ )
251
+ return hints
252
+
253
+
254
+ # ---------- Node -----------------------------------------------------------
255
+
256
+
257
+ def _parse_package_json(path: Path, rel: str) -> list[DependencyHint]:
258
+ data = json.loads(path.read_text(encoding="utf-8"))
259
+ hints: list[DependencyHint] = []
260
+ for section, is_dev in (("dependencies", False), ("devDependencies", True), ("peerDependencies", False), ("optionalDependencies", False)):
261
+ section_data = data.get(section) or {}
262
+ if not isinstance(section_data, dict):
263
+ continue
264
+ for name, version in section_data.items():
265
+ hints.append(
266
+ DependencyHint(
267
+ name=str(name),
268
+ version=str(version),
269
+ ecosystem="npm",
270
+ file=rel,
271
+ dev=is_dev,
272
+ evidence_text=f"{section}.{name} = {version!r}",
273
+ )
274
+ )
275
+ return hints
276
+
277
+
278
+ # ---------- Go -------------------------------------------------------------
279
+
280
+
281
+ _GO_REQUIRE_LINE_RE = re.compile(
282
+ r"^\s*(?P<name>[^\s]+)\s+(?P<version>v[^\s]+)"
283
+ )
284
+
285
+
286
+ def _parse_go_mod(path: Path, rel: str) -> list[DependencyHint]:
287
+ """Parse `require` directives in go.mod (both single-line and block form)."""
288
+ text = path.read_text(encoding="utf-8")
289
+ hints: list[DependencyHint] = []
290
+ in_block = False
291
+ for lineno, raw in enumerate(split_lines(text), start=1):
292
+ stripped = raw.strip()
293
+ if not stripped or stripped.startswith("//"):
294
+ continue
295
+ if stripped.startswith("require ("):
296
+ in_block = True
297
+ continue
298
+ if in_block and stripped == ")":
299
+ in_block = False
300
+ continue
301
+ if in_block:
302
+ body = stripped.split("//", 1)[0].strip()
303
+ m = _GO_REQUIRE_LINE_RE.match(body)
304
+ if m:
305
+ is_indirect = "// indirect" in raw
306
+ hints.append(
307
+ DependencyHint(
308
+ name=m.group("name"),
309
+ version=m.group("version"),
310
+ ecosystem="go",
311
+ file=rel,
312
+ line=lineno,
313
+ dev=is_indirect,
314
+ # go.mod pins exact versions and flags indirect
315
+ # (transitive) deps — treat it as a resolved lockfile.
316
+ resolved=True,
317
+ direct=not is_indirect,
318
+ evidence_text=raw.strip(),
319
+ )
320
+ )
321
+ continue
322
+ if stripped.startswith("require "):
323
+ # single-line: `require module/name v1.2.3`
324
+ body = stripped[len("require "):].split("//", 1)[0].strip()
325
+ m = _GO_REQUIRE_LINE_RE.match(body)
326
+ if m:
327
+ is_indirect = "// indirect" in raw
328
+ hints.append(
329
+ DependencyHint(
330
+ name=m.group("name"),
331
+ version=m.group("version"),
332
+ ecosystem="go",
333
+ file=rel,
334
+ line=lineno,
335
+ dev=is_indirect,
336
+ resolved=True,
337
+ direct=not is_indirect,
338
+ evidence_text=raw.strip(),
339
+ )
340
+ )
341
+ return hints
342
+
343
+
344
+ # ---------- Rust -----------------------------------------------------------
345
+
346
+
347
+ def _parse_cargo_toml(path: Path, rel: str) -> list[DependencyHint]:
348
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
349
+ hints: list[DependencyHint] = []
350
+ for section, is_dev in (
351
+ ("dependencies", False),
352
+ ("dev-dependencies", True),
353
+ ("build-dependencies", True),
354
+ ):
355
+ section_data = data.get(section) or {}
356
+ if not isinstance(section_data, dict):
357
+ continue
358
+ for name, spec in section_data.items():
359
+ if isinstance(spec, str):
360
+ version = spec
361
+ elif isinstance(spec, dict):
362
+ version = str(spec.get("version") or "*")
363
+ else:
364
+ version = "*"
365
+ hints.append(
366
+ DependencyHint(
367
+ name=str(name),
368
+ version=version,
369
+ ecosystem="cargo",
370
+ file=rel,
371
+ dev=is_dev,
372
+ evidence_text=f"[{section}] {name} = {spec!r}",
373
+ )
374
+ )
375
+ return hints
376
+
377
+
378
+ # ---------- PHP ------------------------------------------------------------
379
+
380
+
381
+ def _parse_composer_json(path: Path, rel: str) -> list[DependencyHint]:
382
+ data = json.loads(path.read_text(encoding="utf-8"))
383
+ hints: list[DependencyHint] = []
384
+ for section, is_dev in (("require", False), ("require-dev", True)):
385
+ section_data = data.get(section) or {}
386
+ if not isinstance(section_data, dict):
387
+ continue
388
+ for name, version in section_data.items():
389
+ # composer.json's own "php" / "ext-*" entries are runtime
390
+ # requirements, not third-party packages — skip.
391
+ if name.lower() == "php" or name.lower().startswith("ext-") or name.lower().startswith("lib-"):
392
+ continue
393
+ hints.append(
394
+ DependencyHint(
395
+ name=str(name),
396
+ version=str(version),
397
+ ecosystem="composer",
398
+ file=rel,
399
+ dev=is_dev,
400
+ evidence_text=f"{section}.{name} = {version!r}",
401
+ )
402
+ )
403
+ return hints
404
+
405
+
406
+ # ---------- Dedup ----------------------------------------------------------
407
+
408
+
409
+ def _dedup(hints: list[DependencyHint]) -> list[DependencyHint]:
410
+ seen: set[tuple[str, str, str, str, bool]] = set()
411
+ out: list[DependencyHint] = []
412
+ for h in hints:
413
+ key = (h.ecosystem, h.name, h.version, h.file, h.dev)
414
+ if key in seen:
415
+ continue
416
+ seen.add(key)
417
+ out.append(h)
418
+ return out