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,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"]