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
@@ -0,0 +1,431 @@
1
+ """Declarative taint sink registry (#240).
2
+
3
+ Sinks live in ``taint_sinks.yaml`` next to this module — one entry per
4
+ signature with its kind, languages, regex, gate, dangerous argument(s) and a
5
+ **required** CWE id — and are loaded and validated once at import. The same
6
+ table feeds the taint pass (:mod:`attackmap.taint`), the per-kind labels and
7
+ exploitability base points, and the CWE of each sink.
8
+
9
+ Analyzer plugins contribute sinks through the ``attackmap.taint_sinks``
10
+ entry-point group (documented in :mod:`attackmap.sdk`). The entry point may
11
+ resolve to a list of entry dicts, a callable returning one, or a path to a
12
+ YAML file with a ``sinks:`` list. Plugin entries go through the same
13
+ validation; an invalid plugin entry is skipped with a warning rather than
14
+ failing the scan. Plugins can only use the kinds declared here — the findings,
15
+ taxonomy and exploitability tables are keyed on them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import re
22
+ import sys
23
+ from importlib.metadata import entry_points
24
+ from pathlib import Path
25
+ from typing import Any, NamedTuple
26
+
27
+ import yaml
28
+
29
+ from .taint_flow import RECEIVER, REST
30
+ from .taint_sources import CSHARP, GO, JAVA, JS, PHP, PY, RUBY
31
+
32
+ REGISTRY_PATH = Path(__file__).with_name("taint_sinks.yaml")
33
+ PLUGIN_GROUP = "attackmap.taint_sinks"
34
+
35
+ LANGS: dict[str, str] = {
36
+ "python": PY, "js": JS, "go": GO, "php": PHP, "java": JAVA, "csharp": CSHARP, "ruby": RUBY,
37
+ }
38
+ GATES = ("any", "tainted")
39
+ _ARG_WORDS: dict[str, tuple[int, ...] | None] = {"all": None, "receiver": RECEIVER, "rest": REST}
40
+ _FLAGS = {"ignorecase": re.IGNORECASE, "dotall": re.DOTALL}
41
+
42
+ _REQUIRED = ("id", "kind", "langs", "regex", "cwe")
43
+ _OPTIONAL = (
44
+ "gate", "args", "when", "unless", "receiver_unless", "requires", "flags", "token_gate", "base_score", "note",
45
+ )
46
+ _ID_RE = re.compile(r"[a-z0-9]+(?:[-_.][a-z0-9]+)*")
47
+
48
+
49
+ class SinkRegistryError(ValueError):
50
+ """A sink entry failed validation."""
51
+
52
+
53
+ class KindMeta(NamedTuple):
54
+ label: str
55
+ cwe: int
56
+ base_score: int
57
+ alias_of: str | None = None
58
+
59
+
60
+ class SinkSpec(NamedTuple):
61
+ id: str
62
+ kind: str
63
+ pattern: re.Pattern[str]
64
+ langs: frozenset[str]
65
+ cwe: int
66
+ gate: str = "any"
67
+ args: tuple[int, ...] | None = (0,)
68
+ when: re.Pattern[str] | None = None
69
+ unless: re.Pattern[str] | None = None
70
+ requires: re.Pattern[str] | None = None
71
+ token_gate: bool = True
72
+ base_score: int | None = None
73
+ source: str = "core"
74
+ receiver_unless: re.Pattern[str] | None = None
75
+
76
+
77
+ def _compile(value: Any, field: str, where: str, flags: int = 0) -> re.Pattern[str]:
78
+ if not isinstance(value, str) or not value:
79
+ raise SinkRegistryError(f"{where}: `{field}` must be a non-empty regex string")
80
+ try:
81
+ return re.compile(value, flags)
82
+ except re.error as exc:
83
+ raise SinkRegistryError(f"{where}: `{field}` does not compile: {exc}") from exc
84
+
85
+
86
+ def _cwe(value: Any, where: str) -> int:
87
+ if isinstance(value, str) and value.upper().startswith("CWE-"):
88
+ value = value[4:]
89
+ try:
90
+ n = int(value)
91
+ except (TypeError, ValueError):
92
+ n = 0
93
+ if isinstance(value, bool) or n <= 0:
94
+ raise SinkRegistryError(f"{where}: `cwe` must be a positive CWE id (e.g. 89 or 'CWE-89')")
95
+ return n
96
+
97
+
98
+ def validate_sink_entry(
99
+ entry: Any, kinds: dict[str, KindMeta] | None = None, *, source: str = "core"
100
+ ) -> SinkSpec:
101
+ """Validate one registry entry and compile it. Raises SinkRegistryError.
102
+
103
+ Every sink must name a known ``kind``, at least one language, a compiling
104
+ ``regex`` and a ``cwe`` — a sink without a CWE can't be tagged or mapped,
105
+ so it is rejected outright."""
106
+ kinds = KINDS if kinds is None else kinds
107
+ if not isinstance(entry, dict):
108
+ raise SinkRegistryError(f"{source}: sink entry must be a mapping, got {type(entry).__name__}")
109
+ where = f"{source}: sink {entry.get('id', '<no id>')!r}"
110
+ missing = [k for k in _REQUIRED if k not in entry]
111
+ if missing:
112
+ raise SinkRegistryError(f"{where}: missing required field(s) {', '.join(missing)}")
113
+ unknown = sorted(set(entry) - set(_REQUIRED) - set(_OPTIONAL))
114
+ if unknown:
115
+ raise SinkRegistryError(f"{where}: unknown field(s) {', '.join(unknown)}")
116
+ sid = entry["id"]
117
+ if not isinstance(sid, str) or not _ID_RE.fullmatch(sid):
118
+ raise SinkRegistryError(f"{where}: `id` must be a lowercase slug")
119
+ kind = entry["kind"]
120
+ if kind not in kinds:
121
+ raise SinkRegistryError(f"{where}: unknown kind {kind!r} (known: {', '.join(sorted(kinds))})")
122
+ langs = entry["langs"]
123
+ if not isinstance(langs, list) or not langs or any(lang not in LANGS for lang in langs):
124
+ raise SinkRegistryError(f"{where}: `langs` must be a non-empty list of {', '.join(LANGS)}")
125
+ flags = 0
126
+ for flag in entry.get("flags", []) or []:
127
+ if flag not in _FLAGS:
128
+ raise SinkRegistryError(f"{where}: unknown flag {flag!r}")
129
+ flags |= _FLAGS[flag]
130
+ gate = entry.get("gate", "any")
131
+ if gate not in GATES:
132
+ raise SinkRegistryError(f"{where}: `gate` must be one of {', '.join(GATES)}")
133
+ raw_args = entry.get("args", [0])
134
+ if isinstance(raw_args, str):
135
+ if raw_args not in _ARG_WORDS:
136
+ raise SinkRegistryError(f"{where}: `args` must be a list of ints or one of {', '.join(_ARG_WORDS)}")
137
+ args = _ARG_WORDS[raw_args]
138
+ elif isinstance(raw_args, list) and raw_args and all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 for i in raw_args):
139
+ args = tuple(raw_args)
140
+ else:
141
+ raise SinkRegistryError(f"{where}: `args` must be a non-empty list of argument indexes")
142
+ token_gate = entry.get("token_gate", True)
143
+ if not isinstance(token_gate, bool):
144
+ raise SinkRegistryError(f"{where}: `token_gate` must be a boolean")
145
+ base = entry.get("base_score")
146
+ if base is not None and (not isinstance(base, int) or isinstance(base, bool) or not 0 <= base <= 100):
147
+ raise SinkRegistryError(f"{where}: `base_score` must be an int in 0..100")
148
+ return SinkSpec(
149
+ id=sid,
150
+ kind=kind,
151
+ pattern=_compile(entry["regex"], "regex", where, flags),
152
+ langs=frozenset(LANGS[lang] for lang in langs),
153
+ cwe=_cwe(entry["cwe"], where),
154
+ gate=gate,
155
+ args=args,
156
+ when=_compile(entry["when"], "when", where) if entry.get("when") else None,
157
+ unless=_compile(entry["unless"], "unless", where) if entry.get("unless") else None,
158
+ requires=_compile(entry["requires"], "requires", where) if entry.get("requires") else None,
159
+ token_gate=token_gate,
160
+ base_score=base,
161
+ source=source,
162
+ receiver_unless=(
163
+ _compile(entry["receiver_unless"], "receiver_unless", where) if entry.get("receiver_unless") else None
164
+ ),
165
+ )
166
+
167
+
168
+ _RECEIVER_SCAN_LIMIT = 400
169
+ _ASSIGN_SCAN_LIMIT = 64
170
+ # How far back from the call `_assigned_value` looks for the receiver's
171
+ # assignment. A fixed window keeps a file with many calls linear.
172
+ _ASSIGN_WINDOW = 4000
173
+ _IDENT_RE = re.compile(r"[A-Za-z_$][\w$]{0,63}")
174
+ _IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
175
+ _CLOSERS = {"(": ")", "[": "]", "{": "}"}
176
+ _QUOTES = frozenset("'\"`")
177
+
178
+
179
+ def call_receiver(content: str, dot: int) -> str:
180
+ """The receiver of the method call whose `.` is at ``dot``, with every
181
+ call's or subscript's arguments elided and whitespace dropped:
182
+ `crypto.createHmac()` for `crypto.createHmac('sha256', k).update(x)`,
183
+ across line breaks in a chain. Elision keeps a pattern anchored at the end
184
+ (`…createHmac()$`) to the receiver's own calls, never to a call nested in
185
+ an argument (`db.doc(crypto.createHash(…).digest())`). When the receiver
186
+ is a bare name, the right-hand side of its nearest earlier declarator is
187
+ appended (`h=crypto.createHash()`), so a pattern can see what the variable
188
+ holds. Bounded: ``_RECEIVER_SCAN_LIMIT`` characters back for the receiver,
189
+ ``_ASSIGN_WINDOW`` characters and ``_ASSIGN_SCAN_LIMIT`` mentions for the
190
+ assignment."""
191
+ lo = max(0, dot - _RECEIVER_SCAN_LIMIT)
192
+ i = dot
193
+ while i > lo and content[i - 1] in " \t\r\n":
194
+ i -= 1
195
+ end = i
196
+ while i > lo:
197
+ c = content[i - 1]
198
+ if c in ")]":
199
+ opener = "(" if c == ")" else "["
200
+ depth = 0
201
+ j = i - 1
202
+ while j >= lo:
203
+ if content[j] == c:
204
+ depth += 1
205
+ elif content[j] == opener:
206
+ depth -= 1
207
+ if depth == 0:
208
+ break
209
+ j -= 1
210
+ if j < lo:
211
+ break
212
+ i = j
213
+ elif c in _IDENT_CHARS or c == ".":
214
+ i -= 1
215
+ elif c in " \t\r\n":
216
+ # Whitespace inside a chain only before a `.`: `crypto\n .createHmac(…)`.
217
+ k = i
218
+ while k > lo and content[k - 1] in " \t\r\n":
219
+ k -= 1
220
+ if content[i] != "." and (k == 0 or content[k - 1] != "."):
221
+ break
222
+ i = k
223
+ else:
224
+ break
225
+ receiver = content[i:end].strip()
226
+ if _IDENT_RE.fullmatch(receiver):
227
+ rhs = _assigned_value(content, receiver, i)
228
+ if rhs:
229
+ return f"{receiver}={_elide(rhs)}"
230
+ return _elide(receiver)
231
+
232
+
233
+ def _elide(text: str) -> str:
234
+ """``text`` with the contents of every top-level (), [] and {} group
235
+ dropped (quotes respected) and whitespace removed."""
236
+ out: list[str] = []
237
+ i, n = 0, len(text)
238
+ while i < n:
239
+ c = text[i]
240
+ if c in _CLOSERS:
241
+ j = _group_end(text, i)
242
+ out.append(c + _CLOSERS[c])
243
+ i = j + 1
244
+ continue
245
+ if not c.isspace():
246
+ out.append(c)
247
+ i += 1
248
+ return "".join(out)
249
+
250
+
251
+ def _group_end(text: str, start: int) -> int:
252
+ """Index of the bracket closing the one at ``start`` (or the last index)."""
253
+ depth = 0
254
+ quote = ""
255
+ i = start
256
+ while i < len(text):
257
+ c = text[i]
258
+ if quote:
259
+ if c == "\\":
260
+ i += 1
261
+ elif c == quote:
262
+ quote = ""
263
+ elif c in _QUOTES:
264
+ quote = c
265
+ elif c in "([{":
266
+ depth += 1
267
+ elif c in ")]}":
268
+ depth -= 1
269
+ if depth == 0:
270
+ return i
271
+ i += 1
272
+ return len(text) - 1
273
+
274
+
275
+ def _assigned_value(content: str, name: str, before: int) -> str:
276
+ """The right-hand side of the nearest `name = …` declarator before
277
+ ``before``: up to a top-level `,` or `;` or the end of the line, so
278
+ `const User = require('m'), sig = crypto.createHmac(…)` gives `require('m')`
279
+ for `User`."""
280
+ floor = max(0, before - _ASSIGN_WINDOW)
281
+ pos = before
282
+ for _ in range(_ASSIGN_SCAN_LIMIT):
283
+ pos = content.rfind(name, floor, pos)
284
+ if pos < 0:
285
+ return ""
286
+ after = pos + len(name)
287
+ if (pos > 0 and (content[pos - 1] in _IDENT_CHARS or content[pos - 1] == ".")) or (
288
+ after < len(content) and content[after] in _IDENT_CHARS
289
+ ):
290
+ continue
291
+ k = after
292
+ while k < len(content) and content[k] in " \t":
293
+ k += 1
294
+ if content.startswith("=", k) and not content.startswith(("==", "=>"), k):
295
+ return _declarator(content, k + 1)
296
+ return ""
297
+
298
+
299
+ def _declarator(content: str, start: int) -> str:
300
+ depth = 0
301
+ quote = ""
302
+ i = start
303
+ stop = min(len(content), start + _RECEIVER_SCAN_LIMIT)
304
+ while i < stop:
305
+ c = content[i]
306
+ if quote:
307
+ if c == "\\":
308
+ i += 1
309
+ elif c == quote:
310
+ quote = ""
311
+ elif c == "\n":
312
+ break
313
+ elif c in _QUOTES:
314
+ quote = c
315
+ elif c in "([{":
316
+ depth += 1
317
+ elif c in ")]}":
318
+ depth -= 1
319
+ elif depth <= 0 and c in ",;\n":
320
+ break
321
+ i += 1
322
+ return content[start:i].strip()
323
+
324
+
325
+ def _load_kinds(data: dict) -> dict[str, KindMeta]:
326
+ raw = data.get("kinds")
327
+ if not isinstance(raw, dict) or not raw:
328
+ raise SinkRegistryError("registry: `kinds` must be a non-empty mapping")
329
+ out: dict[str, KindMeta] = {}
330
+ for kind, meta in raw.items():
331
+ where = f"registry: kind {kind!r}"
332
+ if not isinstance(meta, dict) or not isinstance(meta.get("label"), str):
333
+ raise SinkRegistryError(f"{where}: needs a `label`")
334
+ out[kind] = KindMeta(
335
+ label=meta["label"],
336
+ cwe=_cwe(meta.get("cwe"), where),
337
+ base_score=int(meta.get("base_score", 15)),
338
+ alias_of=meta.get("alias_of"),
339
+ )
340
+ return out
341
+
342
+
343
+ def load_registry(path: Path = REGISTRY_PATH) -> tuple[dict[str, KindMeta], tuple[SinkSpec, ...]]:
344
+ """Load and validate a registry file: ``(kinds, sinks)``."""
345
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
346
+ if not isinstance(data, dict):
347
+ raise SinkRegistryError(f"{path.name}: top level must be a mapping")
348
+ kinds = _load_kinds(data)
349
+ raw = data.get("sinks")
350
+ if not isinstance(raw, list) or not raw:
351
+ raise SinkRegistryError(f"{path.name}: `sinks` must be a non-empty list")
352
+ sinks = tuple(validate_sink_entry(e, kinds, source=path.name) for e in raw)
353
+ seen: set[str] = set()
354
+ for s in sinks:
355
+ if s.id in seen:
356
+ raise SinkRegistryError(f"{path.name}: duplicate sink id {s.id!r}")
357
+ seen.add(s.id)
358
+ return kinds, sinks
359
+
360
+
361
+ KINDS, SINKS = load_registry()
362
+ SINK_KINDS: tuple[str, ...] = tuple(KINDS)
363
+
364
+
365
+ def kind_label(kind: str) -> str:
366
+ meta = KINDS.get(kind)
367
+ return meta.label if meta else kind
368
+
369
+
370
+ def kind_base_score(kind: str, default: int = 15) -> int:
371
+ meta = KINDS.get(kind)
372
+ return meta.base_score if meta else default
373
+
374
+
375
+ def canonical_kind(kind: str) -> str:
376
+ """``dynamic_open`` is kept as a schema-compatible alias of
377
+ ``path_traversal`` (#200); everything else maps to itself."""
378
+ meta = KINDS.get(kind)
379
+ return meta.alias_of if meta and meta.alias_of else kind
380
+
381
+
382
+ def _plugin_entries(ep_name: str, obj: Any) -> list[Any]:
383
+ if callable(obj):
384
+ obj = obj()
385
+ if isinstance(obj, (str, Path)):
386
+ data = yaml.safe_load(Path(obj).read_text(encoding="utf-8"))
387
+ obj = data.get("sinks", []) if isinstance(data, dict) else data
388
+ if not isinstance(obj, list):
389
+ raise SinkRegistryError(f"plugin {ep_name}: expected a list of sink entries")
390
+ return obj
391
+
392
+
393
+ def _warn(msg: str) -> None:
394
+ print(f"attackmap: {msg}", file=sys.stderr)
395
+
396
+
397
+ @functools.lru_cache(maxsize=1)
398
+ def plugin_sinks() -> tuple[SinkSpec, ...]:
399
+ """Sinks contributed by installed plugins (``attackmap.taint_sinks``
400
+ entry points), validated; invalid entries are skipped with a warning."""
401
+ out: list[SinkSpec] = []
402
+ seen = {s.id for s in SINKS}
403
+ try:
404
+ eps = entry_points()
405
+ candidates = list(eps.select(group=PLUGIN_GROUP)) if hasattr(eps, "select") else list(eps.get(PLUGIN_GROUP, ()))
406
+ except Exception as exc: # noqa: BLE001 - discovery must never break a scan
407
+ _warn(f"taint sink plugin discovery failed: {exc}")
408
+ return ()
409
+ for ep in candidates:
410
+ try:
411
+ entries = _plugin_entries(ep.name, ep.load())
412
+ except Exception as exc: # noqa: BLE001
413
+ _warn(f"taint sink plugin {ep.name!r} skipped: {exc}")
414
+ continue
415
+ for entry in entries:
416
+ try:
417
+ spec = validate_sink_entry(entry, source=f"plugin {ep.name}")
418
+ except SinkRegistryError as exc:
419
+ _warn(f"{exc} — skipped")
420
+ continue
421
+ if spec.id in seen:
422
+ _warn(f"plugin {ep.name}: duplicate sink id {spec.id!r} — skipped")
423
+ continue
424
+ seen.add(spec.id)
425
+ out.append(spec)
426
+ return tuple(out)
427
+
428
+
429
+ def all_sinks() -> tuple[SinkSpec, ...]:
430
+ """Core registry + plugin contributions."""
431
+ return SINKS + plugin_sinks()