ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_checker_census.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Exhaustive census, immutability, and inline-scope rules for checker constants.
4
5Three fail-closed properties are enforced over every checker module:
6
71. CENSUS -- every module-level ALL_CAPS binding must be classified, either as
8 a typed scope/control authority (``AUTHORITY_SCHEMAS``) or as an explicit
9 non-authority carrying a category reason (``NON_AUTHORITIES``). A constant
10 in neither registry fails; a registry row whose constant no longer exists
11 fails; a non-authority whose value shape contradicts its category fails.
12 Walrus bindings count: ``_ = (ROOTS := ("evil/",))`` binds ``ROOTS`` at
13 module level just as an assignment statement does.
142. IMMUTABILITY -- a registered authority is bound exactly once, at module
15 top level, by a plain assignment. Reassignment, augmented assignment,
16 conditional or nested binding, subscript/attribute stores, ``del``,
17 ``global`` rebinding, mutator method calls, and mutation reached through
18 ANY alias of the object are rejected, so the authenticated digest always
19 describes the runtime value. Aliasing is resolved semantically rather than
20 by token: an alias of a module attribute (``d = mod.AUTHORITY``), a
21 namespace read (``vars(mod)["AUTHORITY"]``, ``getattr(mod, "AUTHORITY")``,
22 ``mod.__dict__["AUTHORITY"]``), a dynamically imported module
23 (``__import__("mod").AUTHORITY``), an unbound mutator
24 (``dict.update(mod.AUTHORITY, ...)``), ``setattr``/``delattr``, and the
25 in-place ``operator`` functions all reach the same guarded object and are
26 all reported.
273. INLINE SCOPE -- path-shaped scope literals may not be smuggled into filter
28 sinks (``startswith``/``endswith`` prefix tuples, or ``Path.parts``
29 membership and set algebra). Both sinks resolve their operand through
30 local names, walrus bindings, aliases, conditional expressions, container
31 constructors, and container algebra, so a literal moved out of the call
32 is still found. Scope data must be a declared module authority.
33"""
34
35from __future__ import annotations
36
37import ast
38import hashlib
39import json
40import re
41from collections.abc import Iterable
42from dataclasses import dataclass
43from pathlib import Path
44
45from suppression_model import Finding
46
47MODULE_CONSTANT_RE = re.compile(r"^_{0,2}[A-Z][A-Z0-9_]*$")
48PATHLIKE_FRAGMENT_RE = re.compile(r"[A-Za-z0-9_.-]/")
49MUTATOR_METHODS = frozenset(
50 {
51 "add",
52 "append",
53 "clear",
54 "discard",
55 "extend",
56 "insert",
57 "pop",
58 "popitem",
59 "remove",
60 "reverse",
61 "setdefault",
62 "sort",
63 "update",
64 }
65)
66OPERATOR_MUTATORS = frozenset(
67 {
68 "delitem",
69 "iadd",
70 "iand",
71 "iconcat",
72 "ifloordiv",
73 "ilshift",
74 "imatmul",
75 "imod",
76 "imul",
77 "ior",
78 "ipow",
79 "irshift",
80 "isub",
81 "itruediv",
82 "ixor",
83 "setitem",
84 }
85)
86ATTRIBUTE_MUTATORS = frozenset({"delattr", "setattr"})
87NAMESPACE_CALLS = frozenset({"globals", "vars"})
88MODULE_IMPORT_CALLS = frozenset({"__import__", "import_module"})
89UNBOUND_MUTATOR_TYPES = frozenset({"bytearray", "dict", "list", "set"})
90CONTAINER_CALLS = frozenset({"frozenset", "list", "set", "tuple"})
91PREFIX_SINK_METHODS = frozenset({"endswith", "startswith"})
92LITERAL_CONTAINERS = (ast.Tuple, ast.List, ast.Set, ast.Dict)
93SCOPE_NODES = (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Lambda)
94SET_ALGEBRA_OPS = (ast.BitAnd, ast.BitOr, ast.BitXor, ast.Sub)
95
96
97def _scope_nodes(scope: ast.AST) -> list[ast.AST]:
98 """Return every node inside one scope, never descending into a nested scope."""
99 nodes: list[ast.AST] = []
100 for child in ast.iter_child_nodes(scope):
101 nodes.append(child)
102 if not isinstance(child, SCOPE_NODES):
103 nodes.extend(_scope_nodes(child))
104 return nodes
105
106
107def _call_name(node: ast.Call) -> str:
108 """Return the simple or attribute name a call invokes, without evaluating it."""
109 func = node.func
110 if isinstance(func, ast.Name):
111 return func.id
112 if isinstance(func, ast.Attribute):
113 return func.attr
114 return ""
115
116
117def _constant_text(node: ast.AST) -> str | None:
118 """Return the exact string a constant expression names, if it names one."""
119 if isinstance(node, ast.Constant) and isinstance(node.value, str):
120 return node.value
121 return None
122
123
124def _name_bindings(nodes: Iterable[ast.AST]) -> list[tuple[str, ast.AST]]:
125 """Return every simple ``name = expression`` binding among some nodes."""
126 pairs: list[tuple[str, ast.AST]] = []
127 for node in nodes:
128 if isinstance(node, ast.Assign):
129 pairs.extend(
130 (target.id, node.value) for target in node.targets if isinstance(target, ast.Name)
131 )
132 elif (
133 isinstance(node, (ast.NamedExpr, ast.AnnAssign))
134 and isinstance(node.target, ast.Name)
135 and node.value
136 ):
137 pairs.append((node.target.id, node.value))
138 return pairs
139
140
141def _statement_bindings(node: ast.stmt) -> list[tuple[str, int, ast.AST | None]]:
142 """Return the ALL_CAPS names one module-level statement binds directly."""
143 bindings: list[tuple[str, int, ast.AST | None]] = []
144 if isinstance(node, ast.Assign):
145 for target in node.targets:
146 names: list[ast.Name] = []
147 if isinstance(target, ast.Name):
148 names = [target]
149 elif isinstance(target, (ast.Tuple, ast.List)):
150 names = [item for item in target.elts if isinstance(item, ast.Name)]
151 bindings.extend(
152 (name.id, node.lineno, node.value)
153 for name in names
154 if MODULE_CONSTANT_RE.match(name.id)
155 )
156 return bindings
157 target = getattr(node, "target", None)
158 if (
159 isinstance(node, (ast.AnnAssign, ast.AugAssign))
160 and isinstance(target, ast.Name)
161 and MODULE_CONSTANT_RE.match(target.id)
162 ):
163 bindings.append((target.id, node.lineno, node.value))
164 return bindings
165
166
167def _walrus_bindings(node: ast.stmt) -> list[tuple[str, int, ast.AST | None]]:
168 """Return the ALL_CAPS names one module-level statement binds by walrus.
169
170 A ``def``/``class``/``lambda`` opens its own namespace, so a walrus inside
171 one binds there, not at module level, and is not a module constant.
172 """
173 if isinstance(node, SCOPE_NODES):
174 return []
175 return [
176 (child.target.id, child.lineno, child.value)
177 for child in _scope_nodes(node)
178 if isinstance(child, ast.NamedExpr)
179 and isinstance(child.target, ast.Name)
180 and MODULE_CONSTANT_RE.match(child.target.id)
181 ]
182
183
184def census_bindings(tree: ast.Module) -> list[tuple[str, int, ast.AST | None]]:
185 """Return every module-level ALL_CAPS binding as ``(name, line, value)``."""
186 bindings: list[tuple[str, int, ast.AST | None]] = []
187 for node in tree.body:
188 bindings.extend(_statement_bindings(node))
189 bindings.extend(_walrus_bindings(node))
190 return bindings
191
192
193def _store_root(node: ast.AST) -> ast.AST:
194 """Return the base expression a subscript/attribute store mutates."""
195 while isinstance(node, (ast.Subscript, ast.Attribute)):
196 node = node.value
197 return node
198
199
200def _store_chain(node: ast.AST) -> list[ast.AST]:
201 """Return a store target followed by every expression it is an element of."""
202 chain = [node]
203 while isinstance(node, (ast.Subscript, ast.Attribute)):
204 node = node.value
205 chain.append(node)
206 return chain
207
208
209def _import_bindings(
210 tree: ast.Module, module_authorities: dict[str, frozenset[str]]
211) -> tuple[dict[str, str], dict[str, str]]:
212 """Return authority-import aliases and checker-module aliases in one file."""
213 imported: dict[str, str] = {}
214 module_aliases: dict[str, str] = {}
215 for node in ast.walk(tree):
216 if isinstance(node, ast.ImportFrom) and node.module is not None and node.level == 0:
217 module = node.module.rpartition(".")[2]
218 names = module_authorities.get(module)
219 if names is None:
220 continue
221 for alias in node.names:
222 if alias.name in names:
223 imported[alias.asname or alias.name] = f"{module}:{alias.name}"
224 elif isinstance(node, ast.Import):
225 for alias in node.names:
226 module = alias.name.rpartition(".")[2]
227 if module in module_authorities:
228 module_aliases[alias.asname or alias.name] = module
229 return imported, module_aliases
230
231
232def _mutation(rel: str, line: int, name: str, how: str) -> Finding:
233 """Build one authority-mutation finding."""
234 return Finding(
235 "checker-authority-mutation",
236 f"{name} is mutated after authentication ({how})",
237 rel,
238 line,
239 )
240
241
242@dataclass(frozen=True)
243class _MutationScan:
244 """One module's guarded names and cross-module authority bindings."""
245
246 rel: str
247 guarded: frozenset[str]
248 module_lookup: dict[str, str]
249 module_authorities: dict[str, frozenset[str]]
250
251 def module_of(self, node: ast.AST) -> str | None:
252 """Return the checker module an expression names, without importing it."""
253 if isinstance(node, ast.Name):
254 return self.module_lookup.get(node.id)
255 if isinstance(node, ast.Call) and _call_name(node) in MODULE_IMPORT_CALLS and node.args:
256 text = _constant_text(node.args[0])
257 module = text.rpartition(".")[2] if text is not None else ""
258 return module if module in self.module_authorities else None
259 return None
260
261 def namespace_of(self, node: ast.AST) -> str | None:
262 """Return the module whose writable namespace mapping an expression is."""
263 if isinstance(node, ast.Call) and _call_name(node) in NAMESPACE_CALLS:
264 return "" if not node.args else self.module_of(node.args[0])
265 if isinstance(node, ast.Attribute) and node.attr == "__dict__":
266 return self.module_of(node.value)
267 return None
268
269 def holds(self, module: str, name: str) -> bool:
270 """Return whether one module binds a registered authority of that name."""
271 if not module:
272 return name in self.guarded
273 return name in self.module_authorities.get(module, frozenset())
274
275 def authority_label(self, node: ast.AST) -> str | None:
276 """Return the label of the registered authority an expression resolves to."""
277 if isinstance(node, ast.Name) and node.id in self.guarded:
278 return node.id
279 if isinstance(node, ast.Attribute):
280 module = self.module_of(node.value)
281 if module is not None and self.holds(module, node.attr):
282 return f"{module}.{node.attr}"
283 if isinstance(node, ast.Subscript):
284 return self._namespace_item(self.namespace_of(node.value), node.slice)
285 if isinstance(node, ast.Call) and _call_name(node) == "getattr" and len(node.args) > 1:
286 return self._namespace_item(self.module_of(node.args[0]), node.args[1])
287 return None
288
289 def _namespace_item(self, module: str | None, key: ast.AST) -> str | None:
290 """Return the label of one authority reached through a module namespace."""
291 name = _constant_text(key)
292 if module is None or name is None or not self.holds(module, name):
293 return None
294 return f"{module}.{name}" if module else name
295
296 def store_findings(
297 self, node: ast.Assign | ast.AugAssign | ast.Delete, targets: list[ast.AST], how: str
298 ) -> list[Finding]:
299 """Reject stores and deletions that reach a guarded object."""
300 findings: list[Finding] = []
301 for target in targets:
302 if isinstance(target, (ast.Subscript, ast.Attribute)):
303 findings.extend(self._element_findings(node.lineno, target, how))
304 elif isinstance(target, ast.Name) and isinstance(node, (ast.AugAssign, ast.Delete)):
305 if target.id in self.guarded:
306 findings.append(_mutation(self.rel, node.lineno, target.id, how))
307 elif isinstance(target, (ast.Tuple, ast.List)):
308 findings.extend(self.store_findings(node, list(target.elts), how))
309 return findings
310
311 def _element_findings(self, line: int, target: ast.AST, how: str) -> list[Finding]:
312 """Reject one subscript or attribute store reaching a registered authority."""
313 root = _store_root(target)
314 if isinstance(root, ast.Name) and root.id in self.guarded:
315 return [_mutation(self.rel, line, root.id, f"{how} via element store")]
316 for index, element in enumerate(_store_chain(target)):
317 label = self.authority_label(element)
318 if label is not None:
319 detail = how if index == 0 else f"{how} via element store"
320 return [_mutation(self.rel, line, label, detail)]
321 return []
322
323 def call_findings(self, node: ast.Call) -> list[Finding]:
324 """Reject mutator method calls and mutating functions on guarded objects."""
325 return self._method_findings(node) + self._function_findings(node)
326
327 def _method_findings(self, node: ast.Call) -> list[Finding]:
328 """Reject a mutator method invoked on an authority or a module namespace."""
329 func = node.func
330 if not isinstance(func, ast.Attribute) or func.attr not in MUTATOR_METHODS:
331 return []
332 how = f".{func.attr}() call"
333 label = self.authority_label(func.value)
334 if label is not None:
335 return [_mutation(self.rel, node.lineno, label, how)]
336 if self.namespace_of(func.value) is not None:
337 return [_mutation(self.rel, node.lineno, "module namespace", f"{how} on a namespace")]
338 unbound = isinstance(func.value, ast.Name) and func.value.id in UNBOUND_MUTATOR_TYPES
339 label = self.authority_label(node.args[0]) if unbound and node.args else None
340 return [_mutation(self.rel, node.lineno, label, f"unbound {how}")] if label else []
341
342 def _function_findings(self, node: ast.Call) -> list[Finding]:
343 """Reject setattr/delattr and in-place operator functions on an authority."""
344 name = _call_name(node)
345 if not node.args or name not in ATTRIBUTE_MUTATORS | OPERATOR_MUTATORS:
346 return []
347 first = node.args[0]
348 module = self.module_of(first) if name in ATTRIBUTE_MUTATORS else None
349 if module is not None:
350 return [_mutation(self.rel, node.lineno, module, f"{name} on checker module")]
351 label = self.authority_label(first)
352 if label is not None:
353 return [_mutation(self.rel, node.lineno, label, f"{name}() call")]
354 if self.namespace_of(first) is not None:
355 return [
356 _mutation(self.rel, node.lineno, "module namespace", f"{name}() on a namespace")
357 ]
358 return []
359
360
361def _protected_aliases(
362 tree: ast.Module,
363 protected: set[str],
364 module_lookup: dict[str, str],
365 module_authorities: dict[str, frozenset[str]],
366) -> set[str]:
367 """Return same-module names bound to a protected authority object."""
368 pairs = _name_bindings(ast.walk(tree))
369 aliases: set[str] = set()
370 changed = True
371 while changed:
372 changed = False
373 scan = _MutationScan("", frozenset(protected | aliases), module_lookup, module_authorities)
374 for name, value in pairs:
375 if name in protected or name in aliases:
376 continue
377 if scan.authority_label(value) is not None:
378 aliases.add(name)
379 changed = True
380 return aliases
381
382
383def _binding_findings(
384 rel: str,
385 node: ast.Assign | ast.AnnAssign,
386 local_authorities: set[str],
387 top_level: set[ast.stmt],
388 bound: set[str],
389) -> list[Finding]:
390 """Reject nested, conditional, and repeated authority bindings."""
391 findings: list[Finding] = []
392 targets = node.targets if isinstance(node, ast.Assign) else [node.target]
393 for target in targets:
394 if not isinstance(target, ast.Name) or target.id not in local_authorities:
395 continue
396 if node not in top_level:
397 problem = f"{target.id} is bound inside control flow or a function"
398 elif target.id in bound:
399 problem = f"{target.id} is bound more than once at module level"
400 else:
401 bound.add(target.id)
402 continue
403 findings.append(Finding("checker-authority-rebinding", problem, rel, node.lineno))
404 return findings
405
406
407def _dynamic_store_finding(rel: str, node: ast.Subscript) -> Finding | None:
408 """Reject writes through globals()/vars() namespaces."""
409 base = node.value
410 if (
411 isinstance(base, ast.Call)
412 and isinstance(base.func, ast.Name)
413 and base.func.id in NAMESPACE_CALLS
414 ):
415 return Finding(
416 "checker-authority-mutation",
417 "dynamic namespace store cannot be statically authenticated",
418 rel,
419 node.lineno,
420 )
421 return None
422
423
424def mutation_findings(
425 rel: str,
426 tree: ast.Module,
427 local_authorities: set[str],
428 module_authorities: dict[str, frozenset[str]],
429) -> list[Finding]:
430 """Reject every write that changes a registered authority after binding."""
431 imported, module_lookup = _import_bindings(tree, module_authorities)
432 protected = set(local_authorities) | set(imported)
433 aliases = _protected_aliases(tree, protected, module_lookup, module_authorities)
434 scan = _MutationScan(rel, frozenset(protected | aliases), module_lookup, module_authorities)
435 findings: list[Finding] = []
436 top_level = set(tree.body)
437 bound: set[str] = set()
438 for node in ast.walk(tree):
439 if isinstance(node, (ast.Assign, ast.AnnAssign)):
440 findings.extend(_binding_findings(rel, node, local_authorities, top_level, bound))
441 targets = node.targets if isinstance(node, ast.Assign) else [node.target]
442 findings.extend(scan.store_findings(node, list(targets), "assignment"))
443 elif isinstance(node, ast.AugAssign):
444 findings.extend(scan.store_findings(node, [node.target], "augmented assignment"))
445 elif isinstance(node, ast.Delete):
446 findings.extend(scan.store_findings(node, list(node.targets), "del"))
447 elif isinstance(node, ast.Global):
448 findings.extend(
449 _mutation(rel, node.lineno, name, "global rebinding")
450 for name in node.names
451 if name in local_authorities
452 )
453 elif isinstance(node, ast.Call):
454 findings.extend(scan.call_findings(node))
455 elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Store):
456 problem = _dynamic_store_finding(rel, node)
457 if problem is not None:
458 findings.append(problem)
459 return findings
460
461
462def _container_elements(node: ast.AST) -> list[ast.AST] | None:
463 """Return a literal container's element nodes, or None if it is not one."""
464 if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
465 return list(node.elts)
466 if isinstance(node, ast.Dict):
467 return [key for key in node.keys if key is not None]
468 return None
469
470
471def _pathlike_container(node: ast.AST) -> bool:
472 """Return whether a literal container carries path-fragment scope strings."""
473 elements = _container_elements(node)
474 if elements is None:
475 return False
476 return any(
477 isinstance(item, ast.Constant)
478 and isinstance(item.value, str)
479 and PATHLIKE_FRAGMENT_RE.search(item.value)
480 for item in elements
481 )
482
483
484def _resolve_containers(
485 node: ast.AST, bindings: dict[str, list[ast.AST]], seen: frozenset[str]
486) -> list[ast.AST]:
487 """Return every literal container one filter-sink operand can evaluate to."""
488 if _container_elements(node) is not None:
489 return [node]
490 if isinstance(node, ast.Name):
491 if node.id not in bindings or node.id in seen:
492 return []
493 nested = seen | {node.id}
494 values = bindings[node.id]
495 return [item for value in values for item in _resolve_containers(value, bindings, nested)]
496 return _resolve_composite(node, bindings, seen)
497
498
499def _resolve_composite(
500 node: ast.AST, bindings: dict[str, list[ast.AST]], seen: frozenset[str]
501) -> list[ast.AST]:
502 """Resolve constructors, walrus bindings, conditionals, and container algebra."""
503 if isinstance(node, ast.NamedExpr):
504 return _resolve_containers(node.value, bindings, seen)
505 if isinstance(node, ast.Call) and _call_name(node) in CONTAINER_CALLS and node.args:
506 return _resolve_containers(node.args[0], bindings, seen)
507 branches: tuple[ast.AST, ...] = ()
508 if isinstance(node, ast.IfExp):
509 branches = (node.body, node.orelse)
510 elif isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, *SET_ALGEBRA_OPS)):
511 branches = (node.left, node.right)
512 return [item for branch in branches for item in _resolve_containers(branch, bindings, seen)]
513
514
515def _scope_bindings(scope: ast.AST, *, module: bool) -> dict[str, list[ast.AST]]:
516 """Return the names one scope binds, minus the censused module constants."""
517 bindings: dict[str, list[ast.AST]] = {}
518 for name, value in _name_bindings(_scope_nodes(scope)):
519 if module and MODULE_CONSTANT_RE.match(name):
520 continue
521 bindings.setdefault(name, []).append(value)
522 return bindings
523
524
525def _scope_table(tree: ast.Module) -> list[tuple[ast.AST, dict[str, list[ast.AST]]]]:
526 """Return every scope paired with the container bindings visible inside it."""
527 table: list[tuple[ast.AST, dict[str, list[ast.AST]]]] = [
528 (tree, _scope_bindings(tree, module=True))
529 ]
530 index = 0
531 while index < len(table):
532 scope, visible = table[index]
533 index += 1
534 table.extend(
535 (node, {**visible, **_scope_bindings(node, module=False)})
536 for node in _scope_nodes(scope)
537 if isinstance(node, SCOPE_NODES)
538 )
539 return table
540
541
542def _reads_parts(node: ast.AST) -> bool:
543 """Return whether an expression reads a path's ``parts`` tuple."""
544 return any(
545 isinstance(child, ast.Attribute) and child.attr == "parts" for child in ast.walk(node)
546 )
547
548
549def _comprehension_comparators(
550 node: ast.GeneratorExp | ast.ListComp | ast.SetComp,
551) -> list[tuple[int, ast.AST]]:
552 """Return the membership operands of a comprehension over ``Path.parts``."""
553 iterates_parts = any(
554 isinstance(generator.iter, ast.Attribute) and generator.iter.attr == "parts"
555 for generator in node.generators
556 )
557 element = getattr(node, "elt", None)
558 if not iterates_parts or element is None:
559 return []
560 return [
561 (node.lineno, compare.comparators[0])
562 for compare in ast.walk(element)
563 if isinstance(compare, ast.Compare)
564 and len(compare.ops) == 1
565 and isinstance(compare.ops[0], (ast.In, ast.NotIn))
566 ]
567
568
569def _membership_comparators(node: ast.AST) -> list[tuple[int, ast.AST]]:
570 """Return the container operands of one ``Path.parts`` scope filter."""
571 if isinstance(node, (ast.GeneratorExp, ast.ListComp, ast.SetComp)):
572 return _comprehension_comparators(node)
573 if isinstance(node, ast.Compare) and len(node.ops) == 1:
574 over_parts = (
575 isinstance(node.ops[0], (ast.In, ast.NotIn))
576 and isinstance(node.left, ast.Attribute)
577 and node.left.attr == "parts"
578 )
579 return [(node.lineno, node.comparators[0])] if over_parts else []
580 if isinstance(node, ast.BinOp) and isinstance(node.op, SET_ALGEBRA_OPS):
581 left = _reads_parts(node.left)
582 if left != _reads_parts(node.right):
583 return [(node.lineno, node.right if left else node.left)]
584 return []
585
586
587def _prefix_sink_findings(
588 rel: str, nodes: list[ast.AST], bindings: dict[str, list[ast.AST]]
589) -> list[Finding]:
590 """Reject path-shaped scope containers reaching a startswith/endswith filter."""
591 findings: list[Finding] = []
592 for node in nodes:
593 is_sink = (
594 isinstance(node, ast.Call)
595 and isinstance(node.func, ast.Attribute)
596 and node.func.attr in PREFIX_SINK_METHODS
597 and node.args
598 )
599 if not is_sink:
600 continue
601 containers = _resolve_containers(node.args[0], bindings, frozenset())
602 if any(_pathlike_container(item) for item in containers):
603 findings.append(
604 Finding(
605 "inline-scope-literal",
606 f".{node.func.attr}() path tuple must be a declared authority",
607 rel,
608 node.lineno,
609 )
610 )
611 return findings
612
613
614def _membership_findings(
615 rel: str, nodes: list[ast.AST], bindings: dict[str, list[ast.AST]]
616) -> list[Finding]:
617 """Reject ``Path.parts`` filters resolved against an undeclared literal."""
618 findings: list[Finding] = []
619 for node in nodes:
620 findings.extend(
621 Finding(
622 "inline-scope-literal",
623 "Path.parts scope filter must use a declared module authority",
624 rel,
625 line,
626 )
627 for line, comparator in _membership_comparators(node)
628 if _resolve_containers(comparator, bindings, frozenset())
629 )
630 return findings
631
632
633def _assertion_nodes(tree: ast.Module) -> set[int]:
634 """Collect nodes inside test expectations, which state facts, not scope."""
635 inside: set[int] = set()
636 for node in ast.walk(tree):
637 is_expectation = isinstance(node, ast.Assert) or (
638 isinstance(node, ast.Call)
639 and isinstance(node.func, ast.Name)
640 and node.func.id == "expect"
641 )
642 if is_expectation:
643 inside.update(id(child) for child in ast.walk(node))
644 return inside
645
646
647def sink_findings(rel: str, tree: ast.Module) -> list[Finding]:
648 """Reject scope literals reaching filter sinks through any binding form."""
649 expectations = _assertion_nodes(tree)
650 findings: list[Finding] = []
651 for scope, bindings in _scope_table(tree):
652 nodes = [node for node in _scope_nodes(scope) if id(node) not in expectations]
653 findings.extend(_prefix_sink_findings(rel, nodes, bindings))
654 findings.extend(_membership_findings(rel, nodes, bindings))
655 return findings
656
657
658CATEGORY_NODE_KINDS = {
659 "exit-code": frozenset({"Constant"}),
660 "numeric-format": frozenset(
661 {"Attribute", "BinOp", "Call", "Constant", "Subscript", "Tuple", "UnaryOp"}
662 ),
663 "derived-runtime": frozenset(
664 {
665 "Attribute",
666 "BinOp",
667 "Call",
668 "Compare",
669 "Dict",
670 "DictComp",
671 "GeneratorExp",
672 "IfExp",
673 "JoinedStr",
674 "List",
675 "ListComp",
676 "Name",
677 "SetComp",
678 "Subscript",
679 }
680 ),
681}
682EMPTY_CONTAINER_CATEGORIES = frozenset({"derived-runtime"})
683FIXTURE_BASENAME_MARKERS = ("selftest", "fixture")
684FIXTURE_OWNER_RELATIVE_PATHS = frozenset(
685 {
686 "scripts/checks/hil_convergence_safety_runtime_loader_harness.py",
687 "scripts/checks/hil_convergence_safety_runtime_sources.py",
688 }
689)
690
691
692def _empty_container(value: ast.AST) -> bool:
693 """Return whether a value is an empty literal container (a runtime cache)."""
694 if isinstance(value, (ast.Tuple, ast.List, ast.Set)):
695 return not value.elts
696 if isinstance(value, ast.Dict):
697 return not value.keys
698 return False
699
700
701def _scope_shaped_literal(value: ast.AST) -> str | None:
702 """Return a path-prefix string literal hiding inside a non-authority value."""
703 for node in ast.walk(value):
704 is_prefix = (
705 isinstance(node, ast.Constant)
706 and isinstance(node.value, str)
707 and node.value.endswith("/")
708 and PATHLIKE_FRAGMENT_RE.search(node.value)
709 )
710 if is_prefix:
711 return node.value
712 return None
713
714
715def shape_problem(rel: str, category: str, value: ast.AST, *, has_selftest: bool) -> str | None:
716 """Return why a non-authority value contradicts its category, if it does."""
717 if category == "selftest-fixture":
718 basename = Path(rel).name
719 marked = rel in FIXTURE_OWNER_RELATIVE_PATHS or any(
720 marker in basename for marker in FIXTURE_BASENAME_MARKERS
721 )
722 if not marked and not has_selftest:
723 return "selftest-fixture classification outside a module with a selftest"
724 return None
725 if category in EMPTY_CONTAINER_CATEGORIES and _empty_container(value):
726 return None
727 allowed = CATEGORY_NODE_KINDS.get(category)
728 if allowed is not None and type(value).__name__ not in allowed:
729 return f"{type(value).__name__} value contradicts category {category}"
730 prefix = _scope_shaped_literal(value)
731 if prefix is not None:
732 return f"path-prefix literal {prefix!r} is scope-shaped data"
733 return None
734
735
736def classification_digest(
737 authority_schemas: dict[str, object], non_authorities: dict[str, str]
738) -> str:
739 """Authenticate the complete constant classification map."""
740 payload = {
741 **dict.fromkeys(authority_schemas, "authority"),
742 **{key: f"non-authority:{category}" for key, category in non_authorities.items()},
743 }
744 encoded = json.dumps(dict(sorted(payload.items())), sort_keys=True, separators=(",", ":"))
745 return hashlib.sha256(encoded.encode()).hexdigest()
746
747
748def repository_mutation_findings(
749 root: Path,
750 paths: list[str],
751 module_authorities: dict[str, frozenset[str]],
752 candidate_paths: frozenset[str],
753) -> list[Finding]:
754 """Reject imported-authority mutation from every tracked Python file."""
755 findings: list[Finding] = []
756 for rel in paths:
757 if not rel.endswith(".py") or rel in candidate_paths:
758 continue
759 try:
760 tree = ast.parse((root / rel).read_text(encoding="utf-8"), filename=rel)
761 except (OSError, SyntaxError) as exc:
762 findings.append(Finding("checker-scope-ast", str(exc), rel))
763 continue
764 findings.extend(mutation_findings(rel, tree, set(), module_authorities))
765 return findings