attackmap-analyzer-omeka-s 0.2.0__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.
@@ -0,0 +1,3 @@
1
+ from .analyzer import OmekaSAnalyzer
2
+
3
+ __all__ = ["OmekaSAnalyzer"]
@@ -0,0 +1,468 @@
1
+ """Omeka S ACL rules and per-route access decisions (attackmap-analyzer-omeka-s#2).
2
+
3
+ Omeka decides who reaches a controller with its ACL (`Omeka\\Permissions\\Acl`,
4
+ a Laminas ACL). Controllers are ACL resources, the action is the privilege,
5
+ and `MvcListeners::authorizeUserAgainstController` checks
6
+ `userIsAllowed($controller, $action)` on every dispatch. The anonymous visitor
7
+ has no role, so only rules for the `null` role ("all roles") reach them.
8
+
9
+ This module reads `$acl->allow(<roles>, <resources>, <privileges>[, <assertion>])`
10
+ and `->deny(...)` calls from a module's `Module.php` (`onBootstrap`,
11
+ `attachListeners` or helpers they call) and from `*AclFactory.php` (Omeka core's
12
+ rules), and answers per controller and action with Laminas' lookup order
13
+ (`Laminas\\Permissions\\Acl\\Acl::isAllowed`):
14
+
15
+ 1. For each resource level, the specific controller first and then the
16
+ all-resources level (`null`; Omeka's controllers have no parent resources):
17
+ the role's own rules (the privilege, then all privileges), then the
18
+ all-roles rules (the privilege, then all privileges). An all-roles,
19
+ all-privileges *deny* on the specific controller doesn't stop the lookup;
20
+ it continues to the all-resources level, as in Laminas.
21
+ 2. For each (resource, role, privilege) slot the last rule written wins.
22
+ 3. No rule: denied (Omeka's default).
23
+
24
+ Two public principals are checked: the anonymous visitor (all-roles rules
25
+ only) and `'guest'` (the Guest module's self-registration role, its own rules
26
+ then all-roles rules; treated as public). Role inheritance isn't modelled:
27
+ Omeka core and the Guest module add their roles without parents.
28
+
29
+ - **anonymous**: either principal is allowed.
30
+ - **required**: both are denied (by a rule or by default). Guards name the
31
+ roles that are allowed (`omeka_acl:requires_role(<role>)`) or
32
+ `omeka_acl:default_deny`.
33
+ - **unknown**: a conditional rule (an assertion) decides, a `:action` route's
34
+ actions disagree, or a rule's role or resource couldn't be resolved (an
35
+ unresolved anonymous resource, or a zero-argument `allow()` outside
36
+ `*AclFactory.php`, turns every deny into unknown).
37
+
38
+ Zero-argument `allow()` in `*AclFactory.php` is Omeka's grant-all while it is
39
+ not installed or needs a migration. The installer and migration controllers
40
+ (``INSTALL_CONTROLLERS``) serve exactly then, so they are never reported as
41
+ `required` (`unknown` with a note instead).
42
+
43
+ Decisions are memoised per (controller, action), rules are indexed by
44
+ (principal, resource, privilege), and an ``Acl`` makes at most
45
+ ``MAX_DECISIONS`` fresh decisions; past that budget it answers `unknown`.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from collections.abc import Callable
51
+ from dataclasses import dataclass, field
52
+
53
+ from .phparray import (
54
+ LPAREN,
55
+ OP,
56
+ WORD,
57
+ NameContext,
58
+ PhpArray,
59
+ PhpExpr,
60
+ PhpString,
61
+ PhpValue,
62
+ Token,
63
+ class_name,
64
+ parse_call_args,
65
+ )
66
+
67
+ ANONYMOUS_ROLES = frozenset({"guest"})
68
+ ALL = "*"
69
+ MAX_RULES_PER_FILE = 2_000
70
+ MAX_DECISIONS = 50_000
71
+ MAX_PRIVILEGES_PER_DECISION = 256
72
+ _OTHER_ACTION = "\0any-other-action"
73
+ # Controllers that run while Omeka isn't installed or needs a migration, when
74
+ # AclFactory grants everything (`$acl->allow()`) and MvcListeners redirects there.
75
+ INSTALL_CONTROLLERS = frozenset({"Omeka\\Controller\\Install", "Omeka\\Controller\\Migrate"})
76
+
77
+
78
+ @dataclass
79
+ class AclRule:
80
+ allow: bool
81
+ file: str
82
+ line: int
83
+ # Role names as written; `null` for the all-roles role. `Acl::ROLE_*`
84
+ # constants are resolved to their values (`Acl::ROLE_GUEST` → `guest`).
85
+ roles: list[str]
86
+ # The role argument couldn't be resolved (a variable): it might be anonymous.
87
+ role_unresolved: bool
88
+ # Resolved resource names; `ALL` for a `null` resource argument.
89
+ resources: list[str]
90
+ resources_unresolved: bool
91
+ # None = every privilege (action).
92
+ privileges: list[str] | None
93
+ privileges_unresolved: bool
94
+ conditional: bool
95
+ # `$acl->allow()` with no arguments (grant-all).
96
+ grant_all: bool = False
97
+
98
+ @property
99
+ def anonymous(self) -> bool:
100
+ return any(role == "null" or role.lower() in ANONYMOUS_ROLES for role in self.roles)
101
+
102
+ @property
103
+ def maybe_anonymous(self) -> bool:
104
+ return self.anonymous or self.role_unresolved
105
+
106
+
107
+ @dataclass
108
+ class Decision:
109
+ auth: str # "anonymous" | "required" | "unknown"
110
+ guards: list[str] = field(default_factory=list)
111
+ evidence: str | None = None
112
+
113
+
114
+ def _role_names(value: PhpValue | None) -> tuple[bool, list[str]]:
115
+ """(unresolved, role names) of a role argument; `null` is the all-roles role."""
116
+ if value is None:
117
+ return False, ["null"]
118
+ values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
119
+ unresolved = False
120
+ roles: list[str] = []
121
+ for item in values:
122
+ text = item.text.strip() if isinstance(item, PhpExpr) else ""
123
+ if isinstance(item, PhpExpr) and text.lower() == "null":
124
+ roles.append("null")
125
+ elif isinstance(item, PhpString):
126
+ roles.append(item.value.strip())
127
+ elif isinstance(item, PhpExpr) and "::ROLE_" in text and " " not in text:
128
+ # `Acl::ROLE_SITE_ADMIN` → `site_admin`, `Acl::ROLE_GUEST` → `guest`:
129
+ # Omeka's constants are the lower-cased suffix.
130
+ roles.append(text.rsplit("::ROLE_", 1)[1].lower())
131
+ else:
132
+ unresolved = True
133
+ return unresolved, roles
134
+
135
+
136
+ def _resource_names(value: PhpValue | None, names: NameContext) -> tuple[list[str], bool]:
137
+ if value is None or (isinstance(value, PhpExpr) and value.text.strip().lower() == "null"):
138
+ return [ALL], False
139
+ values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
140
+ resources: list[str] = []
141
+ unresolved = False
142
+ for item in values:
143
+ if isinstance(item, PhpString) and item.value.strip():
144
+ resources.append(item.value.strip().lstrip("\\"))
145
+ continue
146
+ resolved = class_name(item, names)
147
+ if resolved:
148
+ resources.append(resolved)
149
+ else:
150
+ unresolved = True
151
+ return resources, unresolved
152
+
153
+
154
+ def _privileges(value: PhpValue | None) -> tuple[list[str] | None, bool]:
155
+ if value is None or (isinstance(value, PhpExpr) and value.text.strip().lower() == "null"):
156
+ return None, False
157
+ values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
158
+ privileges = [v.value for v in values if isinstance(v, PhpString)]
159
+ return privileges, len(privileges) != len(values)
160
+
161
+
162
+ def acl_rules(tokens: list[Token], names: NameContext, file: str, line_of: Callable[[int], int]) -> list[AclRule]:
163
+ """Every `->allow(...)` / `->deny(...)` call. ``line_of`` maps a source
164
+ offset to its 1-based line."""
165
+ rules: list[AclRule] = []
166
+ n = len(tokens)
167
+ i = 0
168
+ while i < n - 3 and len(rules) < MAX_RULES_PER_FILE:
169
+ if not (
170
+ tokens[i].kind == OP
171
+ and tokens[i].text == "-"
172
+ and tokens[i + 1].kind == OP
173
+ and tokens[i + 1].text == ">"
174
+ and tokens[i + 2].kind == WORD
175
+ and tokens[i + 2].text in ("allow", "deny")
176
+ and tokens[i + 3].kind == LPAREN
177
+ ):
178
+ i += 1
179
+ continue
180
+ verb = tokens[i + 2].text
181
+ offset = tokens[i + 2].offset
182
+ args, end = parse_call_args(tokens, i + 3)
183
+ i = max(end, i + 4)
184
+ if not args:
185
+ rules.append(
186
+ AclRule(
187
+ allow=verb == "allow",
188
+ file=file,
189
+ line=line_of(offset),
190
+ roles=["null"],
191
+ role_unresolved=False,
192
+ resources=[ALL],
193
+ resources_unresolved=False,
194
+ privileges=None,
195
+ privileges_unresolved=False,
196
+ conditional=False,
197
+ grant_all=True,
198
+ )
199
+ )
200
+ continue
201
+ role_unresolved, roles = _role_names(args[0])
202
+ resources, resources_unresolved = _resource_names(args[1] if len(args) > 1 else None, names)
203
+ privileges, privileges_unresolved = _privileges(args[2] if len(args) > 2 else None)
204
+ conditional = len(args) > 3 and not (isinstance(args[3], PhpExpr) and args[3].text.strip().lower() == "null")
205
+ rules.append(
206
+ AclRule(
207
+ allow=verb == "allow",
208
+ file=file,
209
+ line=line_of(offset),
210
+ roles=roles,
211
+ role_unresolved=role_unresolved,
212
+ resources=resources,
213
+ resources_unresolved=resources_unresolved,
214
+ privileges=privileges,
215
+ privileges_unresolved=privileges_unresolved,
216
+ conditional=conditional,
217
+ )
218
+ )
219
+ return rules
220
+
221
+
222
+ def _describe(rule: AclRule, resource: str) -> str:
223
+ """The rule as written, narrowed to the resource that matched."""
224
+ roles = [role if role == "null" else f"'{role}'" for role in rule.roles]
225
+ if rule.role_unresolved:
226
+ roles.append("<unresolved>")
227
+ role = roles[0] if len(roles) == 1 else "[" + ", ".join(roles) + "]"
228
+ shown = "null" if resource == ALL else f"'{resource}'"
229
+ if len(rule.resources) > 1:
230
+ shown += f" (1 of {len(rule.resources)} resources)"
231
+ args = [role, shown]
232
+ if rule.privileges is not None:
233
+ args.append("[" + ", ".join(f"'{p}'" for p in rule.privileges) + "]")
234
+ if rule.conditional:
235
+ args.append("<assertion>")
236
+ verb = "allow" if rule.allow else "deny"
237
+ return f"$acl->{verb}({', '.join(args)}) at {rule.file}:{rule.line}"
238
+
239
+
240
+ # A lookup result: True/False (allowed/denied by a rule), None (a conditional
241
+ # rule decides: unknown), or "default" (no rule: Omeka's default deny).
242
+
243
+
244
+ class Acl:
245
+ """The repo's ACL rules, indexed by principal and (resource, privilege)."""
246
+
247
+ def __init__(
248
+ self,
249
+ rules: list[AclRule],
250
+ registered: set[str] | None = None,
251
+ aliases: set[str] | None = None,
252
+ ) -> None:
253
+ self.rules = rules
254
+ # None: registrations unknown (unit use); otherwise the ACL resources
255
+ # Omeka adds (controllers.invokables / factories keys).
256
+ self.registered = registered
257
+ self.aliases = aliases or set()
258
+ # Something we couldn't resolve might allow the visitor anything.
259
+ self.blocked = any(
260
+ (r.maybe_anonymous and r.resources_unresolved)
261
+ or (r.grant_all and not r.file.endswith("AclFactory.php"))
262
+ for r in rules
263
+ )
264
+ # Per principal: resource → the rules naming it, in program order
265
+ # (each rule once per resource it names, never expanded per
266
+ # privilege), so the index stays linear in the source.
267
+ self.all_roles: dict[str, list[tuple[int, AclRule]]] = {}
268
+ self.guest: dict[str, list[tuple[int, AclRule]]] = {}
269
+ self.unresolved_role_resources: set[str] = set()
270
+ self.allowed_roles: dict[str, set[str]] = {}
271
+ self.unresolved_privileges: set[str] = set()
272
+ self._privilege_sets: dict[int, frozenset[str]] = {}
273
+ for seq, rule in enumerate(rules):
274
+ if rule.grant_all:
275
+ continue # install-time grant (AclFactory) or a blocker (above)
276
+ if rule.privileges is not None:
277
+ self._privilege_sets[id(rule)] = frozenset(rule.privileges)
278
+ tables = []
279
+ for role in rule.roles:
280
+ if role == "null":
281
+ tables.append(self.all_roles)
282
+ elif role.lower() in ANONYMOUS_ROLES:
283
+ tables.append(self.guest)
284
+ for resource in rule.resources:
285
+ if rule.privileges_unresolved:
286
+ self.unresolved_privileges.add(resource)
287
+ if rule.role_unresolved:
288
+ self.unresolved_role_resources.add(resource)
289
+ for table in tables:
290
+ table.setdefault(resource, []).append((seq, rule))
291
+ if rule.allow:
292
+ for role in rule.roles:
293
+ if role != "null" and role.lower() not in ANONYMOUS_ROLES:
294
+ self.allowed_roles.setdefault(resource, set()).add(role)
295
+ self._slot_memo: dict[tuple[int, str, str | None], tuple[int, AclRule] | None] = {}
296
+ self._memo: dict[tuple[str, str | None], Decision] = {}
297
+ self.fresh_decisions = 0
298
+
299
+ # -- Laminas lookup -------------------------------------------------
300
+
301
+ def _written(self, table: dict[str, list[tuple[int, AclRule]]], resource: str, privilege: str | None):
302
+ """The last rule written to the (resource, privilege) slot directly:
303
+ ``(seq, rule)`` or None. ``privilege`` None is the all-privileges slot."""
304
+ key = (id(table), resource, privilege)
305
+ if key in self._slot_memo:
306
+ return self._slot_memo[key]
307
+ found = None
308
+ for seq, rule in reversed(table.get(resource, ())):
309
+ if privilege is None:
310
+ if rule.privileges is None:
311
+ found = (seq, rule)
312
+ break
313
+ elif rule.privileges is not None and privilege in self._privilege_sets[id(rule)]:
314
+ found = (seq, rule)
315
+ break
316
+ self._slot_memo[key] = found
317
+ return found
318
+
319
+ def _slot(self, table: dict[str, list[tuple[int, AclRule]]], level: str, privilege: str | None):
320
+ """The rule in a slot. Laminas' setRule applies a `null`-resource rule
321
+ to every resource registered so far, overwriting their slots; Omeka
322
+ registers every controller before adding rules, so at a specific
323
+ level the later of the specific rule and the `null`-resource rule wins."""
324
+ own = self._written(table, level, privilege)
325
+ if level == ALL:
326
+ return own[1] if own else None
327
+ spread = self._written(table, ALL, privilege)
328
+ if own is None or (spread is not None and spread[0] > own[0]):
329
+ return spread[1] if spread else None
330
+ return own[1]
331
+
332
+ def _lookup(self, tables: list[tuple[str, dict[str, list[tuple[int, AclRule]]]]], resource: str, privilege: str):
333
+ """(result, rule, resource level) for one principal; see the module docstring."""
334
+ for level in (resource, ALL) if resource != ALL else (ALL,):
335
+ for kind, table in tables:
336
+ rule = self._slot(table, level, privilege)
337
+ if rule is not None:
338
+ return (None if rule.conditional else rule.allow), rule, level
339
+ rule = self._slot(table, level, None)
340
+ if rule is not None:
341
+ if rule.conditional:
342
+ return None, rule, level
343
+ if kind == "role" or rule.allow or level == ALL:
344
+ return rule.allow, rule, level
345
+ # All-roles, all-privileges deny on the specific
346
+ # resource: Laminas continues to the parent level.
347
+ return "default", None, None
348
+
349
+ def _privileges_for(self, resource: str) -> set[str] | None:
350
+ """Privileges public rules name for ``resource`` or all resources;
351
+ None past ``MAX_PRIVILEGES_PER_DECISION``."""
352
+ found: set[str] = set()
353
+ for table in (self.all_roles, self.guest):
354
+ for level in (resource, ALL):
355
+ for _seq, rule in table.get(level, ()):
356
+ if rule.privileges is not None:
357
+ found.update(rule.privileges)
358
+ if len(found) > MAX_PRIVILEGES_PER_DECISION:
359
+ return None
360
+ return found
361
+
362
+ def _public(self, resource: str, privilege: str):
363
+ """Is the visitor or a guest allowed? Returns (result, rule, level)."""
364
+ visitor = self._lookup([("all", self.all_roles)], resource, privilege)
365
+ if visitor[0] is True:
366
+ return visitor
367
+ guest = self._lookup([("role", self.guest), ("all", self.all_roles)], resource, privilege)
368
+ if guest[0] is True:
369
+ return guest
370
+ if visitor[0] is None or guest[0] is None:
371
+ return None, visitor[1] or guest[1], visitor[2] or guest[2]
372
+ # Both denied: report the visitor's reason.
373
+ return visitor
374
+
375
+ # -- decisions --------------------------------------------------------
376
+
377
+ def decide(self, controller: str, action: str | None) -> Decision:
378
+ """Access to ``controller``/``action`` (None = any action, from `:action`)."""
379
+ key = (controller, action)
380
+ cached = self._memo.get(key)
381
+ if cached is not None:
382
+ return cached
383
+ if self.fresh_decisions >= MAX_DECISIONS:
384
+ return Decision("unknown") # per-repo budget spent
385
+ self.fresh_decisions += 1
386
+ decision = self._decide(controller, action)
387
+ if decision.auth == "required" and controller in INSTALL_CONTROLLERS:
388
+ decision = Decision(
389
+ "unknown",
390
+ [],
391
+ f"{controller} serves while Omeka isn't installed or needs a migration, when AclFactory grants "
392
+ "every role everything ($acl->allow()) and MvcListeners redirects there; after that: "
393
+ + (decision.evidence or ""),
394
+ )
395
+ self._memo[key] = decision
396
+ return decision
397
+
398
+ def _decide(self, controller: str, action: str | None) -> Decision:
399
+ if self.registered is not None and controller not in self.registered:
400
+ if controller in self.aliases:
401
+ return Decision(
402
+ "anonymous",
403
+ [],
404
+ f"'{controller}' is a controller alias, not an ACL resource (Omeka adds only "
405
+ "controllers.invokables/factories keys), and authorizeUserAgainstController allows a "
406
+ "controller that isn't a resource",
407
+ )
408
+ return Decision("unknown") # registered somewhere we didn't see, or not at all
409
+ if controller in self.unresolved_role_resources or ALL in self.unresolved_role_resources:
410
+ return Decision("unknown")
411
+ # A privilege list we couldn't read (a variable or constant) might
412
+ # name any action, fixed or not.
413
+ if controller in self.unresolved_privileges or ALL in self.unresolved_privileges:
414
+ return Decision("unknown")
415
+ if action is not None:
416
+ result, rule, level = self._public(controller, action)
417
+ return self._from_result(controller, result, rule, level, action)
418
+ privileges = self._privileges_for(controller)
419
+ if privileges is None:
420
+ return Decision("unknown")
421
+ outcomes = [self._public(controller, p) for p in sorted(privileges)]
422
+ other = self._public(controller, _OTHER_ACTION)
423
+ results = {o[0] for o in outcomes} | {other[0]}
424
+ if results == {True}:
425
+ return self._from_result(controller, True, other[1], other[2], None)
426
+ if results <= {False, "default"}:
427
+ return self._from_result(controller, other[0], other[1], other[2], None)
428
+ return Decision("unknown") # some actions public, some not, or conditional
429
+
430
+ def _from_result(self, controller: str, result, rule: AclRule | None, level: str | None, action: str | None):
431
+ if result is True and rule is not None and level is not None:
432
+ return Decision("anonymous", [], _describe(rule, level))
433
+ if result is None:
434
+ return Decision("unknown")
435
+ if self.blocked:
436
+ return Decision("unknown")
437
+ roles = sorted(self.allowed_roles.get(controller, set()) | self.allowed_roles.get(ALL, set()))
438
+ if result is False and rule is not None and level is not None:
439
+ guards = ["omeka_acl:deny_anonymous", *(f"omeka_acl:requires_role({r})" for r in roles)]
440
+ return Decision("required", guards, _describe(rule, level))
441
+ guards = [f"omeka_acl:requires_role({role})" for role in roles] or ["omeka_acl:default_deny"]
442
+ if roles:
443
+ basis = f"requires_role: {', '.join(roles)}"
444
+ else:
445
+ basis = "requires_role: only roles allowed every resource, site_admin and global_admin in stock Omeka"
446
+ what = f"{controller} action '{action}'" if action is not None else controller
447
+ return Decision(
448
+ "required",
449
+ guards,
450
+ f"Omeka ACL: no rule allows the anonymous role {what}; Omeka denies by default and checks "
451
+ f"the role on every dispatch ({basis})",
452
+ )
453
+
454
+
455
+ def combine(decisions: list[Decision], namespace: str) -> Decision:
456
+ """One decision for a `:controller` route, which reaches every controller
457
+ registered under ``namespace``: anonymous or required only if all agree."""
458
+ if not decisions:
459
+ return Decision("unknown")
460
+ kinds = {d.auth for d in decisions}
461
+ if len(kinds) != 1 or kinds == {"unknown"}:
462
+ return Decision("unknown")
463
+ auth = kinds.pop()
464
+ evidence = sorted({d.evidence or "" for d in decisions})
465
+ shown = "; ".join(evidence[:3]) + (f"; +{len(evidence) - 3} more" if len(evidence) > 3 else "")
466
+ summary = f"all {len(decisions)} controllers under {namespace}: {shown}"
467
+ guards = sorted({g for d in decisions for g in d.guards})
468
+ return Decision(auth, guards, summary)