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/taint_sinks.py
ADDED
|
@@ -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()
|