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
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Field-level mass assignment (#242, part 2).
|
|
2
|
+
|
|
3
|
+
``weaknesses.py`` catches the wholesale bind of a request object
|
|
4
|
+
(``Model(**request.json)``, ``.create(req.body)``, ``permit!``). This pass
|
|
5
|
+
catches binds that look scoped but still let a client set a privileged
|
|
6
|
+
column, which needs the model's field list from another file:
|
|
7
|
+
|
|
8
|
+
Django REST Framework serializer / ModelForm with ``fields = '__all__'``
|
|
9
|
+
(or an ``exclude`` denylist) on a model with privileged fields
|
|
10
|
+
Django ``CreateView`` / ``UpdateView`` with ``fields = '__all__'``
|
|
11
|
+
a SQLModel ``table=True`` model used as a FastAPI write-endpoint body
|
|
12
|
+
a Spring ``@Entity`` bound by ``@ModelAttribute`` / ``@RequestBody``
|
|
13
|
+
|
|
14
|
+
Precision-first: a bind is flagged only when the bound model has a field from
|
|
15
|
+
``PRIVILEGED_FIELDS`` (or inherits Django's ``is_staff`` / ``is_superuser``)
|
|
16
|
+
that the binder doesn't mark read-only or exclude. An explicit field list is
|
|
17
|
+
never flagged, even if it names a privileged field: that's a choice, not an
|
|
18
|
+
accident.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .models import CodeWeakness
|
|
29
|
+
from .safe_fs import is_oversized, walk_repo
|
|
30
|
+
from .srcpaths import is_skipped_dir, is_test_file, is_vendored_file
|
|
31
|
+
|
|
32
|
+
# Privileged column names, normalized (lowercase, no `_`), so `is_admin`,
|
|
33
|
+
# `isAdmin` and `IsAdmin` all match.
|
|
34
|
+
PRIVILEGED_FIELDS = frozenset({
|
|
35
|
+
"isstaff", "isadmin", "admin", "issuperuser", "superuser",
|
|
36
|
+
"role", "roles", "userrole", "permissions", "userpermissions", "privileges",
|
|
37
|
+
"balance", "accountbalance", "credits",
|
|
38
|
+
"verified", "isverified", "emailverified",
|
|
39
|
+
"tenantid", "tenant", "orgid", "organizationid",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# Fields Django's auth base classes add to a subclass.
|
|
43
|
+
_DJANGO_USER_FIELDS = frozenset({"is_staff", "is_superuser", "groups", "user_permissions"})
|
|
44
|
+
_DJANGO_BUILTIN_BASES = {
|
|
45
|
+
"AbstractUser": _DJANGO_USER_FIELDS,
|
|
46
|
+
"User": _DJANGO_USER_FIELDS,
|
|
47
|
+
"PermissionsMixin": frozenset({"is_superuser", "groups", "user_permissions"}),
|
|
48
|
+
}
|
|
49
|
+
_DJANGO_AUTH_MODULES = ("django.contrib.auth.models", "django.contrib.auth")
|
|
50
|
+
|
|
51
|
+
# Binder base classes: DRF serializers and Django forms. FilterSets, graphene
|
|
52
|
+
# types and mutations (whose input type, not `Meta.model`, sets the writable
|
|
53
|
+
# fields) and tables also carry `Meta.model`, and don't match.
|
|
54
|
+
_BINDER_BASE_RE = re.compile(r"(?:Serializer|Form)$")
|
|
55
|
+
_WRITE_VIEW_BASE_RE = re.compile(r"(?:CreateView|UpdateView)$")
|
|
56
|
+
# Django admin classes: a form wired in as `form = X` is only reachable by
|
|
57
|
+
# staff, who are meant to edit privileged fields.
|
|
58
|
+
_ADMIN_BASE_RE = re.compile(r"(?:Admin|Inline)$")
|
|
59
|
+
_ADMIN_FORM_ATTRS = ("form", "add_form")
|
|
60
|
+
_READ_ONLY_SERIALIZER_FIELDS = frozenset({"ReadOnlyField", "SerializerMethodField", "HiddenField"})
|
|
61
|
+
_FASTAPI_WRITE_METHODS = frozenset({"post", "put", "patch"})
|
|
62
|
+
_FASTAPI_INJECTED = frozenset({"Depends", "Security"})
|
|
63
|
+
|
|
64
|
+
# Cheap gates so most files skip the parse: Python files need a class (a
|
|
65
|
+
# model or binder) or a write-route decorator.
|
|
66
|
+
_PY_GATE_RE = re.compile(r"^\s*class\s|\.(?:post|put|patch)\s*\(", re.MULTILINE)
|
|
67
|
+
_JVM_GATE_RE = re.compile(r"@(?:Entity|ModelAttribute|RequestBody)\b")
|
|
68
|
+
|
|
69
|
+
# Spring: an `@Entity` class header and the binding of a handler parameter.
|
|
70
|
+
_JVM_ENTITY_RE = re.compile(r"@(?:jakarta\.persistence\.|javax\.persistence\.)?Entity\b")
|
|
71
|
+
_JVM_CLASS_RE = re.compile(r"\bclass\s+([A-Z]\w*)")
|
|
72
|
+
_JVM_ANNOTATION_RE = re.compile(r"@[\w.]+(?:\s*\([^()\n]*\))?")
|
|
73
|
+
_KOTLIN_PROPERTY_RE = re.compile(r"\b(?:val|var)\s+(\w+)\s*:")
|
|
74
|
+
_SPRING_BIND_RE = re.compile(
|
|
75
|
+
r"@(ModelAttribute|RequestBody)\b(?:\s*\([^()\n]*\))?\s+"
|
|
76
|
+
r"(?:@[\w.]+(?:\s*\([^()\n]*\))?\s+)*"
|
|
77
|
+
r"(?:(?:final\s+)?([A-Z]\w*)\s+(\w+)|(\w+)\s*:\s*([A-Z]\w*)\??)\s*[,)]"
|
|
78
|
+
)
|
|
79
|
+
_SPRING_MAPPING_RE = re.compile(r"@(Get|Post|Put|Patch|Delete|Request)Mapping\b")
|
|
80
|
+
_SPRING_ALLOWED_FIELDS_RE = re.compile(r"\.setAllowedFields\s*\(")
|
|
81
|
+
_SPRING_DISALLOWED_FIELDS_RE = re.compile(r"\.setDisallowedFields\s*\(([^)]*)\)")
|
|
82
|
+
_QUOTED_RE = re.compile(r"\"([^\"\n]{1,80})\"")
|
|
83
|
+
_JSON_READ_ONLY_RE = re.compile(r"@JsonIgnore\b|READ_ONLY|@JsonProperty\s*\([^)]*access")
|
|
84
|
+
|
|
85
|
+
_JVM_SUFFIXES = frozenset({".java", ".kt"})
|
|
86
|
+
_SUFFIXES = frozenset({".py"}) | _JVM_SUFFIXES
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _norm(name: str) -> str:
|
|
90
|
+
return name.replace("_", "").lower()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _privileged(fields: set[str] | frozenset[str]) -> list[str]:
|
|
94
|
+
return sorted(f for f in fields if _norm(f) in PRIVILEGED_FIELDS or f in _DJANGO_USER_FIELDS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class _ModelDef:
|
|
99
|
+
name: str
|
|
100
|
+
file: str
|
|
101
|
+
fields: set[str]
|
|
102
|
+
bases: list[str]
|
|
103
|
+
# JVM fields a JSON body can't set (`@JsonIgnore`, `READ_ONLY`).
|
|
104
|
+
json_read_only: set[str] = field(default_factory=set)
|
|
105
|
+
table: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class _Binder:
|
|
110
|
+
file: str
|
|
111
|
+
line: int
|
|
112
|
+
label: str # "UserSerializer", "POST create_hero(hero)", …
|
|
113
|
+
model: str # model class name, or "" for Django's own User
|
|
114
|
+
imported_from: str | None
|
|
115
|
+
excluded: set[str] = field(default_factory=set)
|
|
116
|
+
read_only: set[str] = field(default_factory=set)
|
|
117
|
+
builtin_user: bool = False
|
|
118
|
+
json_body: bool = False
|
|
119
|
+
# A FastAPI body is only a mass-assignment risk when it's the DB model
|
|
120
|
+
# itself; a plain pydantic model is the DTO that prevents it.
|
|
121
|
+
requires_table: bool = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Index:
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.models: dict[str, list[_ModelDef]] = {}
|
|
127
|
+
self.binders: list[_Binder] = []
|
|
128
|
+
self.admin_forms: set[str] = set()
|
|
129
|
+
|
|
130
|
+
# --- Python --------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def add_python(self, rel: str, text: str) -> None:
|
|
133
|
+
if not _PY_GATE_RE.search(text):
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
tree = ast.parse(text)
|
|
137
|
+
except (SyntaxError, ValueError, RecursionError, MemoryError):
|
|
138
|
+
return
|
|
139
|
+
imports = _python_imports(tree)
|
|
140
|
+
user_aliases = {
|
|
141
|
+
name for name, module in imports.items() if module in _DJANGO_AUTH_MODULES and name == "User"
|
|
142
|
+
}
|
|
143
|
+
for node in tree.body:
|
|
144
|
+
# `User = get_user_model()` at module level.
|
|
145
|
+
if isinstance(node, ast.Assign) and _is_get_user_model(node.value):
|
|
146
|
+
user_aliases.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
|
147
|
+
for node in ast.walk(tree):
|
|
148
|
+
if isinstance(node, ast.ClassDef):
|
|
149
|
+
self._python_class(rel, node, imports, user_aliases)
|
|
150
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
151
|
+
self._fastapi_handler(rel, node, imports)
|
|
152
|
+
|
|
153
|
+
def _python_class(self, rel: str, node: ast.ClassDef, imports: dict[str, str], user_aliases: set[str]) -> None:
|
|
154
|
+
bases = [_terminal_name(b) for b in node.bases]
|
|
155
|
+
table = any(
|
|
156
|
+
kw.arg == "table" and isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
157
|
+
for kw in node.keywords
|
|
158
|
+
)
|
|
159
|
+
fields: set[str] = set()
|
|
160
|
+
read_only: set[str] = set()
|
|
161
|
+
meta: ast.ClassDef | None = None
|
|
162
|
+
class_attrs: dict[str, ast.AST] = {}
|
|
163
|
+
for stmt in node.body:
|
|
164
|
+
if isinstance(stmt, ast.ClassDef) and stmt.name == "Meta":
|
|
165
|
+
meta = stmt
|
|
166
|
+
elif isinstance(stmt, ast.Assign):
|
|
167
|
+
for target in stmt.targets:
|
|
168
|
+
if isinstance(target, ast.Name):
|
|
169
|
+
class_attrs[target.id] = stmt
|
|
170
|
+
if isinstance(stmt.value, ast.Call):
|
|
171
|
+
fields.add(target.id)
|
|
172
|
+
if _is_read_only_serializer_field(stmt.value):
|
|
173
|
+
read_only.add(target.id)
|
|
174
|
+
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
175
|
+
fields.add(stmt.target.id)
|
|
176
|
+
self.models.setdefault(node.name, []).append(
|
|
177
|
+
_ModelDef(name=node.name, file=rel, fields=fields, bases=bases, table=table)
|
|
178
|
+
)
|
|
179
|
+
if any(_ADMIN_BASE_RE.search(b) for b in bases):
|
|
180
|
+
for attr in _ADMIN_FORM_ATTRS:
|
|
181
|
+
stmt = class_attrs.get(attr)
|
|
182
|
+
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Name):
|
|
183
|
+
self.admin_forms.add(stmt.value.id)
|
|
184
|
+
|
|
185
|
+
if meta is not None and any(_BINDER_BASE_RE.search(b) for b in bases):
|
|
186
|
+
attrs = {
|
|
187
|
+
t.id: s
|
|
188
|
+
for s in meta.body
|
|
189
|
+
if isinstance(s, ast.Assign)
|
|
190
|
+
for t in s.targets
|
|
191
|
+
if isinstance(t, ast.Name)
|
|
192
|
+
}
|
|
193
|
+
self._python_binder(rel, node.name, attrs, imports, user_aliases, read_only)
|
|
194
|
+
elif any(_WRITE_VIEW_BASE_RE.search(b) for b in bases) and "form_class" not in class_attrs:
|
|
195
|
+
self._python_binder(rel, node.name, class_attrs, imports, user_aliases, set())
|
|
196
|
+
|
|
197
|
+
def _python_binder(
|
|
198
|
+
self,
|
|
199
|
+
rel: str,
|
|
200
|
+
label: str,
|
|
201
|
+
attrs: dict[str, ast.AST],
|
|
202
|
+
imports: dict[str, str],
|
|
203
|
+
user_aliases: set[str],
|
|
204
|
+
read_only: set[str],
|
|
205
|
+
) -> None:
|
|
206
|
+
model_stmt = attrs.get("model")
|
|
207
|
+
if not isinstance(model_stmt, ast.Assign):
|
|
208
|
+
return
|
|
209
|
+
fields_stmt = attrs.get("fields")
|
|
210
|
+
exclude_stmt = attrs.get("exclude")
|
|
211
|
+
if isinstance(fields_stmt, ast.Assign):
|
|
212
|
+
if not (isinstance(fields_stmt.value, ast.Constant) and fields_stmt.value.value == "__all__"):
|
|
213
|
+
return # an explicit allow-list
|
|
214
|
+
anchor = fields_stmt
|
|
215
|
+
excluded: set[str] = set()
|
|
216
|
+
elif isinstance(exclude_stmt, ast.Assign):
|
|
217
|
+
anchor = exclude_stmt
|
|
218
|
+
excluded = _string_items(exclude_stmt.value)
|
|
219
|
+
else:
|
|
220
|
+
return
|
|
221
|
+
read_only = set(read_only)
|
|
222
|
+
ro_stmt = attrs.get("read_only_fields")
|
|
223
|
+
if isinstance(ro_stmt, ast.Assign):
|
|
224
|
+
read_only |= _string_items(ro_stmt.value)
|
|
225
|
+
kw_stmt = attrs.get("extra_kwargs")
|
|
226
|
+
if isinstance(kw_stmt, ast.Assign) and isinstance(kw_stmt.value, ast.Dict):
|
|
227
|
+
for key, value in zip(kw_stmt.value.keys, kw_stmt.value.values):
|
|
228
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str) and _dict_flag(value, "read_only"):
|
|
229
|
+
read_only.add(key.value)
|
|
230
|
+
|
|
231
|
+
model_expr = model_stmt.value
|
|
232
|
+
builtin = _is_get_user_model(model_expr)
|
|
233
|
+
model = "" if builtin else _terminal_name(model_expr)
|
|
234
|
+
if model in user_aliases:
|
|
235
|
+
builtin, model = True, ""
|
|
236
|
+
if not builtin and not model:
|
|
237
|
+
return
|
|
238
|
+
self.binders.append(
|
|
239
|
+
_Binder(
|
|
240
|
+
file=rel,
|
|
241
|
+
line=anchor.lineno,
|
|
242
|
+
label=label,
|
|
243
|
+
model=model,
|
|
244
|
+
imported_from=imports.get(model),
|
|
245
|
+
excluded=excluded,
|
|
246
|
+
read_only=read_only,
|
|
247
|
+
builtin_user=builtin,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _fastapi_handler(self, rel: str, node: ast.FunctionDef | ast.AsyncFunctionDef, imports: dict[str, str]) -> None:
|
|
252
|
+
method = ""
|
|
253
|
+
for dec in node.decorator_list:
|
|
254
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute) and dec.func.attr in _FASTAPI_WRITE_METHODS:
|
|
255
|
+
method = dec.func.attr.upper()
|
|
256
|
+
break
|
|
257
|
+
if not method:
|
|
258
|
+
return
|
|
259
|
+
positional = node.args.posonlyargs + node.args.args
|
|
260
|
+
defaults: list[ast.AST | None] = [None] * (len(positional) - len(node.args.defaults))
|
|
261
|
+
defaults += node.args.defaults + node.args.kw_defaults
|
|
262
|
+
for arg, default in zip(positional + node.args.kwonlyargs, defaults):
|
|
263
|
+
if arg.annotation is None:
|
|
264
|
+
continue
|
|
265
|
+
if isinstance(default, ast.Call) and _terminal_name(default.func) in _FASTAPI_INJECTED:
|
|
266
|
+
continue # `current_user: User = Depends(...)` is injected, not bound
|
|
267
|
+
model = _terminal_name(arg.annotation)
|
|
268
|
+
if not model:
|
|
269
|
+
continue
|
|
270
|
+
self.binders.append(
|
|
271
|
+
_Binder(
|
|
272
|
+
file=rel,
|
|
273
|
+
line=arg.lineno,
|
|
274
|
+
label=f"{method} {node.name}({arg.arg})",
|
|
275
|
+
model=model,
|
|
276
|
+
imported_from=imports.get(model),
|
|
277
|
+
json_body=True,
|
|
278
|
+
requires_table=True,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# --- Java / Kotlin -------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def add_jvm(self, rel: str, text: str) -> None:
|
|
285
|
+
if not _JVM_GATE_RE.search(text):
|
|
286
|
+
return
|
|
287
|
+
entities = list(_JVM_ENTITY_RE.finditer(text))
|
|
288
|
+
for i, match in enumerate(entities):
|
|
289
|
+
end = entities[i + 1].start() if i + 1 < len(entities) else len(text)
|
|
290
|
+
region = text[match.end():end]
|
|
291
|
+
header = _JVM_CLASS_RE.search(region)
|
|
292
|
+
if header is None:
|
|
293
|
+
continue
|
|
294
|
+
fields, read_only = _jvm_fields(region[header.end():], kotlin=rel.endswith(".kt"))
|
|
295
|
+
self.models.setdefault(header.group(1), []).append(
|
|
296
|
+
_ModelDef(
|
|
297
|
+
name=header.group(1), file=rel, fields=fields, bases=[], json_read_only=read_only, table=True
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if _SPRING_ALLOWED_FIELDS_RE.search(text):
|
|
302
|
+
return # an @InitBinder allow-list governs this controller's binds
|
|
303
|
+
disallowed: set[str] = set()
|
|
304
|
+
for match in _SPRING_DISALLOWED_FIELDS_RE.finditer(text):
|
|
305
|
+
disallowed.update(_QUOTED_RE.findall(match.group(1)))
|
|
306
|
+
for match in _SPRING_BIND_RE.finditer(text):
|
|
307
|
+
kind = match.group(1)
|
|
308
|
+
model = match.group(2) or match.group(5)
|
|
309
|
+
param = match.group(3) or match.group(4)
|
|
310
|
+
method = _spring_method(text, match.start())
|
|
311
|
+
if method is None:
|
|
312
|
+
continue
|
|
313
|
+
self.binders.append(
|
|
314
|
+
_Binder(
|
|
315
|
+
file=rel,
|
|
316
|
+
line=text.count("\n", 0, match.start()) + 1,
|
|
317
|
+
label=f"{method} @{kind} {param}",
|
|
318
|
+
model=model,
|
|
319
|
+
imported_from=None,
|
|
320
|
+
excluded=disallowed if kind == "ModelAttribute" else set(),
|
|
321
|
+
json_body=kind == "RequestBody",
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# --- resolution ----------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def findings(self) -> list[CodeWeakness]:
|
|
328
|
+
out: list[CodeWeakness] = []
|
|
329
|
+
for binder in self.binders:
|
|
330
|
+
if binder.label in self.admin_forms:
|
|
331
|
+
continue
|
|
332
|
+
if binder.builtin_user:
|
|
333
|
+
model_name, fields, json_ro = "User (django.contrib.auth)", set(_DJANGO_USER_FIELDS), set()
|
|
334
|
+
else:
|
|
335
|
+
model = self._resolve(binder.model, binder.file, binder.imported_from)
|
|
336
|
+
if model is None:
|
|
337
|
+
continue
|
|
338
|
+
if binder.requires_table and not model.table:
|
|
339
|
+
continue
|
|
340
|
+
model_name, fields, json_ro = model.name, self._all_fields(model), model.json_read_only
|
|
341
|
+
removed = binder.excluded | binder.read_only | (json_ro if binder.json_body else set())
|
|
342
|
+
writable = _privileged({f for f in fields if f not in removed})
|
|
343
|
+
if not writable:
|
|
344
|
+
continue
|
|
345
|
+
what = "binds every field of" if not binder.excluded else "binds all but the excluded fields of"
|
|
346
|
+
out.append(
|
|
347
|
+
CodeWeakness(
|
|
348
|
+
kind="mass_assignment",
|
|
349
|
+
file=binder.file,
|
|
350
|
+
line=binder.line,
|
|
351
|
+
evidence_text=(
|
|
352
|
+
f"{binder.label} {what} {model_name}; client-writable privileged field(s): "
|
|
353
|
+
+ ", ".join(writable[:6])
|
|
354
|
+
),
|
|
355
|
+
severity="high",
|
|
356
|
+
source_analyzer="weaknesses",
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
def _resolve(self, name: str, from_file: str, imported_from: str | None) -> _ModelDef | None:
|
|
362
|
+
# A Java binder never binds a Python class of the same name, or vice versa.
|
|
363
|
+
jvm = _is_jvm(from_file)
|
|
364
|
+
candidates = [c for c in self.models.get(name, []) if _is_jvm(c.file) == jvm]
|
|
365
|
+
if not candidates:
|
|
366
|
+
return None
|
|
367
|
+
same = [c for c in candidates if c.file == from_file]
|
|
368
|
+
if same:
|
|
369
|
+
return same[0]
|
|
370
|
+
if imported_from:
|
|
371
|
+
matches = [c for c in candidates if _module_matches(c.file, from_file, imported_from)]
|
|
372
|
+
if len(matches) == 1:
|
|
373
|
+
return matches[0]
|
|
374
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
375
|
+
|
|
376
|
+
def _all_fields(self, model: _ModelDef, depth: int = 0) -> set[str]:
|
|
377
|
+
fields = set(model.fields)
|
|
378
|
+
if depth >= 4:
|
|
379
|
+
return fields
|
|
380
|
+
for base in model.bases:
|
|
381
|
+
builtin = _DJANGO_BUILTIN_BASES.get(base)
|
|
382
|
+
if builtin is not None and base not in self.models:
|
|
383
|
+
fields |= builtin
|
|
384
|
+
continue
|
|
385
|
+
parent = self._resolve(base, model.file, None)
|
|
386
|
+
if parent is not None and parent is not model:
|
|
387
|
+
fields |= self._all_fields(parent, depth + 1)
|
|
388
|
+
elif builtin is not None:
|
|
389
|
+
fields |= builtin
|
|
390
|
+
return fields
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- helpers ---------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _is_jvm(rel: str) -> bool:
|
|
397
|
+
return rel.endswith(tuple(_JVM_SUFFIXES))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _terminal_name(node: ast.AST) -> str:
|
|
401
|
+
if isinstance(node, ast.Name):
|
|
402
|
+
return node.id
|
|
403
|
+
if isinstance(node, ast.Attribute):
|
|
404
|
+
return node.attr
|
|
405
|
+
return ""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _is_get_user_model(node: ast.AST) -> bool:
|
|
409
|
+
return isinstance(node, ast.Call) and _terminal_name(node.func) == "get_user_model"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _is_read_only_serializer_field(call: ast.Call) -> bool:
|
|
413
|
+
if _terminal_name(call.func) in _READ_ONLY_SERIALIZER_FIELDS:
|
|
414
|
+
return True
|
|
415
|
+
return any(
|
|
416
|
+
kw.arg == "read_only" and isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
417
|
+
for kw in call.keywords
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _dict_flag(node: ast.AST, key: str) -> bool:
|
|
422
|
+
if not isinstance(node, ast.Dict):
|
|
423
|
+
return False
|
|
424
|
+
return any(
|
|
425
|
+
isinstance(k, ast.Constant) and k.value == key and isinstance(v, ast.Constant) and v.value is True
|
|
426
|
+
for k, v in zip(node.keys, node.values)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _string_items(node: ast.AST) -> set[str]:
|
|
431
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
432
|
+
return {e.value for e in node.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)}
|
|
433
|
+
return set()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _python_imports(tree: ast.Module) -> dict[str, str]:
|
|
437
|
+
"""Name → source module for top-level `from X import Name` (relative
|
|
438
|
+
imports keep their leading dots)."""
|
|
439
|
+
out: dict[str, str] = {}
|
|
440
|
+
for node in tree.body:
|
|
441
|
+
if isinstance(node, ast.ImportFrom):
|
|
442
|
+
module = "." * node.level + (node.module or "")
|
|
443
|
+
for alias in node.names:
|
|
444
|
+
out[alias.asname or alias.name] = module
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _module_matches(def_file: str, from_file: str, module: str) -> bool:
|
|
449
|
+
"""Does `from <module> import X` in ``from_file`` plausibly name ``def_file``?"""
|
|
450
|
+
def_mod = def_file.replace("\\", "/").rsplit(".", 1)[0]
|
|
451
|
+
def_mod = def_mod.removesuffix("/__init__").replace("/", ".")
|
|
452
|
+
if module.startswith("."):
|
|
453
|
+
level = len(module) - len(module.lstrip("."))
|
|
454
|
+
base = from_file.replace("\\", "/").split("/")[:-1]
|
|
455
|
+
base = base[: len(base) - (level - 1)] if level > 1 else base
|
|
456
|
+
target = ".".join(base + ([module.lstrip(".")] if module.lstrip(".") else []))
|
|
457
|
+
return def_mod == target
|
|
458
|
+
return def_mod == module or def_mod.endswith("." + module)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _jvm_fields(body: str, kotlin: bool) -> tuple[set[str], set[str]]:
|
|
462
|
+
"""Field names declared in a Java/Kotlin class body, and the subset a JSON
|
|
463
|
+
body can't set. Line-based: a declaration ends with `;` (Java) or is a
|
|
464
|
+
`val`/`var` property (Kotlin)."""
|
|
465
|
+
fields: set[str] = set()
|
|
466
|
+
read_only: set[str] = set()
|
|
467
|
+
pending = ""
|
|
468
|
+
for raw in body.splitlines():
|
|
469
|
+
line = raw.split("//", 1)[0].strip()
|
|
470
|
+
if not line:
|
|
471
|
+
continue
|
|
472
|
+
if kotlin:
|
|
473
|
+
names = _KOTLIN_PROPERTY_RE.findall(line)
|
|
474
|
+
fields.update(names)
|
|
475
|
+
if _JSON_READ_ONLY_RE.search(pending + line):
|
|
476
|
+
read_only.update(names)
|
|
477
|
+
pending = pending + line if line.startswith("@") and not names else ""
|
|
478
|
+
continue
|
|
479
|
+
if line.startswith("@") and not line.endswith(";"):
|
|
480
|
+
pending += line
|
|
481
|
+
continue
|
|
482
|
+
annotations = pending + " ".join(_JVM_ANNOTATION_RE.findall(line))
|
|
483
|
+
pending = ""
|
|
484
|
+
decl = _JVM_ANNOTATION_RE.sub(" ", line)
|
|
485
|
+
if not decl.rstrip().endswith(";"):
|
|
486
|
+
continue
|
|
487
|
+
decl = decl.rstrip()[:-1].split("=", 1)[0].strip()
|
|
488
|
+
if "(" in decl or decl.startswith(("return", "import", "package", "throw")):
|
|
489
|
+
continue
|
|
490
|
+
tokens = decl.split()
|
|
491
|
+
if len(tokens) < 2 or not tokens[-1].isidentifier():
|
|
492
|
+
continue
|
|
493
|
+
fields.add(tokens[-1])
|
|
494
|
+
if _JSON_READ_ONLY_RE.search(annotations):
|
|
495
|
+
read_only.add(tokens[-1])
|
|
496
|
+
return fields, read_only
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _spring_method(text: str, offset: int) -> str | None:
|
|
500
|
+
"""The HTTP method of the mapping annotation the bound parameter belongs
|
|
501
|
+
to, or None for a read (GET/DELETE) or when there's no mapping."""
|
|
502
|
+
window = text[max(0, offset - 800):offset]
|
|
503
|
+
mappings = list(_SPRING_MAPPING_RE.finditer(window))
|
|
504
|
+
if not mappings:
|
|
505
|
+
return None
|
|
506
|
+
last = mappings[-1]
|
|
507
|
+
verb = last.group(1).upper()
|
|
508
|
+
if verb in ("GET", "DELETE"):
|
|
509
|
+
return None
|
|
510
|
+
if verb == "REQUEST":
|
|
511
|
+
annotation = window[last.start():]
|
|
512
|
+
if "RequestMethod.GET" in annotation or "RequestMethod.DELETE" in annotation:
|
|
513
|
+
return None
|
|
514
|
+
return "POST"
|
|
515
|
+
return verb
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _iter_sources(root: Path):
|
|
519
|
+
for path in walk_repo(root, prune=is_skipped_dir):
|
|
520
|
+
if path.suffix not in _SUFFIXES or is_oversized(path):
|
|
521
|
+
continue
|
|
522
|
+
rel = path.relative_to(root).as_posix()
|
|
523
|
+
if is_test_file(rel) or is_vendored_file(rel):
|
|
524
|
+
continue
|
|
525
|
+
try:
|
|
526
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
527
|
+
except OSError:
|
|
528
|
+
continue
|
|
529
|
+
yield rel, path.suffix, text
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def analyze_mass_assignment(root: str | Path) -> list[CodeWeakness]:
|
|
533
|
+
"""Field-level mass-assignment weaknesses across the repo at ``root``."""
|
|
534
|
+
index = _Index()
|
|
535
|
+
for rel, suffix, text in _iter_sources(Path(root)):
|
|
536
|
+
if suffix == ".py":
|
|
537
|
+
index.add_python(rel, text)
|
|
538
|
+
else:
|
|
539
|
+
index.add_jvm(rel, text)
|
|
540
|
+
return index.findings()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
__all__ = ["PRIVILEGED_FIELDS", "analyze_mass_assignment"]
|