3"""Conservative AST and process-launcher parsing for Python lock policy."""
5from __future__
import annotations
9from collections.abc
import Mapping
10from dataclasses
import dataclass
13@dataclass(frozen=True)
15 """Record imported process launchers and Python-interpreter aliases."""
17 modules: Mapping[str, str]
18 functions: Mapping[str, str]
19 sys_modules: frozenset[str]
20 sys_executables: frozenset[str]
23def propagate_member_aliases(
24 bindings: Mapping[str, ast.AST],
29 """Extend one imported module/member pair through unique assignments."""
30 unresolved = dict(bindings)
32 resolved: list[str] = []
33 for name, value
in unresolved.items():
34 if isinstance(value, ast.Name)
and value.id
in modules:
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
52def assigned_process_alias(
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):
65 module = modules.get(value.value.id)
66 if module
is None or value.attr
not in allowed[module]:
68 return "function", module
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]],
77 """Extend imported process launchers through unique assignments."""
78 unresolved = dict(bindings)
80 resolved: list[str] = []
81 for name, value
in unresolved.items():
82 alias = assigned_process_alias(value, modules, functions, allowed)
86 target = modules
if kind ==
"module" else functions
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()
102 "os": {
"popen",
"system"},
103 "subprocess": {
"call",
"check_call",
"check_output",
"Popen",
"run"},
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(
126 frozenset(sys_modules),
127 frozenset(sys_executables),
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)
157 for name, values
in candidates.items()
158 if store_counts.get(name) == 1
and len(values) == 1
164 bindings: Mapping[str, ast.AST],
165 seen: frozenset[str] = frozenset(),
167 """Resolve one bounded literal string expression without executing it."""
168 if isinstance(node, ast.Constant)
and isinstance(node.value, str):
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)
185 return "".join(pieces)
189def literal_command_words(
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)):
199 words: list[str] = []
200 for element
in node.elts:
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:
211 words.append(
"python")
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):
223 module = aliases.modules.get(function.value.id)
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"})
230def process_command_argument(node: ast.Call) -> ast.AST |
None:
231 """Return a process call's positional or explicit args= command expression."""
234 return next((item.value
for item
in node.keywords
if item.arg ==
"args"),
None)
237def forbidden_argv(words: list[str]) -> str |
None:
238 """Classify forbidden installer argv without executing or resolving it."""
241 command = words[0].replace(
"\\",
"/").rsplit(
"/", maxsplit=1)[-1].lower()
242 if command
in {
"uvx",
"uvx.exe"}:
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] == [
253 return "raw pip install"
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):
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"