ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
python_lock_policy_process.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Conservative AST and process-launcher parsing for Python lock policy."""
4
5from __future__ import annotations
6
7import ast
8import re
9from collections.abc import Mapping
10from dataclasses import dataclass
11
12
13@dataclass(frozen=True)
14class ProcessAliases:
15 """Record imported process launchers and Python-interpreter aliases."""
16
17 modules: Mapping[str, str]
18 functions: Mapping[str, str]
19 sys_modules: frozenset[str]
20 sys_executables: frozenset[str]
21
22
23def propagate_member_aliases(
24 bindings: Mapping[str, ast.AST],
25 modules: set[str],
26 functions: set[str],
27 member: str,
28) -> None:
29 """Extend one imported module/member pair through unique assignments."""
30 unresolved = dict(bindings)
31 while unresolved:
32 resolved: list[str] = []
33 for name, value in unresolved.items():
34 if isinstance(value, ast.Name) and value.id in modules:
35 modules.add(name)
36 elif (isinstance(value, ast.Name) and value.id in functions) or (
37 isinstance(value, ast.Attribute)
38 and isinstance(value.value, ast.Name)
39 and value.value.id in modules
40 and value.attr == member
41 ):
42 functions.add(name)
43 else:
44 continue
45 resolved.append(name)
46 if not resolved:
47 return
48 for name in resolved:
49 del unresolved[name]
50
51
52def assigned_process_alias(
53 value: ast.AST,
54 modules: Mapping[str, str],
55 functions: Mapping[str, str],
56 allowed: Mapping[str, set[str]],
57) -> tuple[str, str] | None:
58 """Resolve one assignment to an imported process module or launcher."""
59 if isinstance(value, ast.Name) and value.id in modules:
60 return "module", modules[value.id]
61 if isinstance(value, ast.Name) and value.id in functions:
62 return "function", functions[value.id]
63 if not isinstance(value, ast.Attribute) or not isinstance(value.value, ast.Name):
64 return None
65 module = modules.get(value.value.id)
66 if module is None or value.attr not in allowed[module]:
67 return None
68 return "function", module
69
70
71def propagate_process_aliases(
72 bindings: Mapping[str, ast.AST],
73 modules: dict[str, str],
74 functions: dict[str, str],
75 allowed: Mapping[str, set[str]],
76) -> None:
77 """Extend imported process launchers through unique assignments."""
78 unresolved = dict(bindings)
79 while unresolved:
80 resolved: list[str] = []
81 for name, value in unresolved.items():
82 alias = assigned_process_alias(value, modules, functions, allowed)
83 if alias is None:
84 continue
85 kind, module = alias
86 target = modules if kind == "module" else functions
87 target[name] = module
88 resolved.append(name)
89 if not resolved:
90 return
91 for name in resolved:
92 del unresolved[name]
93
94
95def process_aliases(tree: ast.AST) -> ProcessAliases:
96 """Resolve simple aliases for process launchers and sys.executable."""
97 modules: dict[str, str] = {}
98 functions: dict[str, str] = {}
99 sys_modules: set[str] = set()
100 sys_executables: set[str] = set()
101 allowed = {
102 "os": {"popen", "system"},
103 "subprocess": {"call", "check_call", "check_output", "Popen", "run"},
104 }
105 for node in ast.walk(tree):
106 if isinstance(node, ast.Import):
107 for alias in node.names:
108 if alias.name in allowed:
109 modules[alias.asname or alias.name] = alias.name
110 elif alias.name == "sys":
111 sys_modules.add(alias.asname or alias.name)
112 elif isinstance(node, ast.ImportFrom) and node.module in allowed:
113 for alias in node.names:
114 if alias.name in allowed[node.module]:
115 functions[alias.asname or alias.name] = node.module
116 elif isinstance(node, ast.ImportFrom) and node.module == "sys":
117 for alias in node.names:
118 if alias.name == "executable":
119 sys_executables.add(alias.asname or alias.name)
120 bindings = literal_bindings(tree)
121 propagate_process_aliases(bindings, modules, functions, allowed)
122 propagate_member_aliases(bindings, sys_modules, sys_executables, "executable")
123 return ProcessAliases(
124 modules,
125 functions,
126 frozenset(sys_modules),
127 frozenset(sys_executables),
128 )
129
130
131def literal_bindings(tree: ast.AST) -> dict[str, ast.AST]:
132 """Return uniquely assigned literal candidates safe for conservative resolution."""
133 store_counts: dict[str, int] = {}
134 candidates: dict[str, list[ast.AST]] = {}
135 for node in ast.walk(tree):
136 if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
137 store_counts[node.id] = store_counts.get(node.id, 0) + 1
138 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
139 arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
140 if node.args.vararg is not None:
141 arguments.append(node.args.vararg)
142 if node.args.kwarg is not None:
143 arguments.append(node.args.kwarg)
144 for argument in arguments:
145 store_counts[argument.arg] = store_counts.get(argument.arg, 0) + 1
146 if isinstance(node, ast.Assign) and len(node.targets) == 1:
147 target = node.targets[0]
148 if isinstance(target, ast.Name):
149 candidates.setdefault(target.id, []).append(node.value)
150 elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
151 if node.value is not None:
152 candidates.setdefault(node.target.id, []).append(node.value)
153 elif isinstance(node, ast.NamedExpr) and isinstance(node.target, ast.Name):
154 candidates.setdefault(node.target.id, []).append(node.value)
155 return {
156 name: values[0]
157 for name, values in candidates.items()
158 if store_counts.get(name) == 1 and len(values) == 1
159 }
160
161
162def literal_string(
163 node: ast.AST,
164 bindings: Mapping[str, ast.AST],
165 seen: frozenset[str] = frozenset(),
166) -> str | None:
167 """Resolve one bounded literal string expression without executing it."""
168 if isinstance(node, ast.Constant) and isinstance(node.value, str):
169 return node.value
170 if isinstance(node, ast.Name) and node.id in bindings and node.id not in seen:
171 return literal_string(bindings[node.id], bindings, seen | {node.id})
172 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
173 left = literal_string(node.left, bindings, seen)
174 right = literal_string(node.right, bindings, seen)
175 return None if left is None or right is None else left + right
176 if isinstance(node, ast.JoinedStr):
177 pieces: list[str] = []
178 for value in node.values:
179 if isinstance(value, ast.Constant) and isinstance(value.value, str):
180 pieces.append(value.value)
181 else:
182 # Keep dynamic holes from joining two safe fragments into a
183 # different forbidden token during conservative evaluation.
184 pieces.append(" ")
185 return "".join(pieces)
186 return None
187
188
189def literal_command_words(
190 node: ast.AST,
191 aliases: ProcessAliases,
192 bindings: Mapping[str, ast.AST],
193) -> list[str] | None:
194 """Return literal argv words, representing aliased sys.executable as Python."""
195 if isinstance(node, ast.Name) and node.id in bindings:
196 return literal_command_words(bindings[node.id], aliases, bindings)
197 if not isinstance(node, (ast.List, ast.Tuple)):
198 return None
199 words: list[str] = []
200 for element in node.elts:
201 is_python = (
202 isinstance(element, ast.Attribute)
203 and isinstance(element.value, ast.Name)
204 and element.value.id in aliases.sys_modules
205 and element.attr == "executable"
206 ) or (isinstance(element, ast.Name) and element.id in aliases.sys_executables)
207 value = literal_string(element, bindings)
208 if value is not None:
209 words.append(value)
210 elif is_python:
211 words.append("python")
212 else:
213 words.append("")
214 return words
215
216
217def is_process_call(function: ast.AST, aliases: ProcessAliases) -> bool:
218 """Return whether a call target resolves to a supported process launcher."""
219 if isinstance(function, ast.Name):
220 return function.id in aliases.functions
221 if not isinstance(function, ast.Attribute) or not isinstance(function.value, ast.Name):
222 return False
223 module = aliases.modules.get(function.value.id)
224 return (
225 module == "subprocess"
226 and function.attr in {"call", "check_call", "check_output", "Popen", "run"}
227 ) or (module == "os" and function.attr in {"popen", "system"})
228
229
230def process_command_argument(node: ast.Call) -> ast.AST | None:
231 """Return a process call's positional or explicit args= command expression."""
232 if node.args:
233 return node.args[0]
234 return next((item.value for item in node.keywords if item.arg == "args"), None)
235
236
237def forbidden_argv(words: list[str]) -> str | None:
238 """Classify forbidden installer argv without executing or resolving it."""
239 if not words:
240 return None
241 command = words[0].replace("\\", "/").rsplit("/", maxsplit=1)[-1].lower()
242 if command in {"uvx", "uvx.exe"}:
243 return "uvx"
244 if command in {"uv", "uv.exe"} and words[1:3] == ["pip", "install"]:
245 return "uv pip install"
246 if re.fullmatch(r"pip(?:3(?:\.[0-9]+)?)?(?:\.exe)?", command) and words[1:2] == ["install"]:
247 return "raw pip install"
248 if re.fullmatch(r"(?:py|python(?:3(?:\.[0-9]+)?)?)(?:\.exe)?", command) and words[1:4] == [
249 "-m",
250 "pip",
251 "install",
252 ]:
253 return "raw pip install"
254 return None
255
256
257def shell_installer_label(text: str) -> str | None:
258 """Classify a literal shell command containing an unlocked installer."""
259 if re.search(r"\buvx\b", text):
260 return "uvx"
261 if re.search(r"\buv\s+pip\s+install\b", text):
262 return "uv pip install"
263 interpreter = r"(?:py|python(?:3(?:\.[0-9]+)?)?)(?:\.exe)?"
264 pip = r"pip(?:3(?:\.[0-9]+)?)?(?:\.exe)?"
265 if re.search(rf"\b(?:{interpreter}\s+-m\s+)?{pip}\s+install\b", text):
266 return "raw pip install"
267 return None