db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
493 lines
14 KiB
Python
493 lines
14 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""AST validation for generated Python code."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import builtins as _builtins
|
|
from typing import Any
|
|
|
|
_PYTHON_BUILTIN_NAMES: frozenset[str] = frozenset(dir(_builtins))
|
|
|
|
# Allowed imports that generated code may use.
|
|
ALLOWED_IMPORTS: set[str] = {
|
|
"asyncio",
|
|
"pathlib",
|
|
"json",
|
|
"math",
|
|
"datetime",
|
|
"time",
|
|
"itertools",
|
|
"functools",
|
|
"collections",
|
|
"typing",
|
|
"dataclasses",
|
|
"decimal",
|
|
"fractions",
|
|
"re",
|
|
"base64",
|
|
"hashlib",
|
|
"uuid",
|
|
"random",
|
|
"os", # Limited to os.environ, os.path - validated via attribute access
|
|
}
|
|
|
|
# Blocked imports that expose dangerous capabilities.
|
|
BLOCKED_IMPORTS: set[str] = {
|
|
"sys",
|
|
"subprocess",
|
|
"socket",
|
|
"urllib",
|
|
"requests",
|
|
"http",
|
|
"ftplib",
|
|
"smtplib",
|
|
"telnetlib",
|
|
"multiprocessing",
|
|
"threading",
|
|
"ctypes",
|
|
"shutil",
|
|
"tempfile",
|
|
"importlib",
|
|
"builtins",
|
|
"__builtin__",
|
|
}
|
|
|
|
# Allowed `os` attribute names. Generated code may only touch `os.environ` and
|
|
# `os.path`; everything else (file I/O, process control, mutating helpers, etc.)
|
|
# is rejected by default. Users may pass a custom allow-list via
|
|
# ``allowed_os_attrs`` on the validator entry points.
|
|
ALLOWED_OS_ATTRS: set[str] = {"environ", "path"}
|
|
|
|
# Allowed builtin function names that generated code may call.
|
|
# Note: getattr/setattr/hasattr/delattr are NOT included because they can bypass
|
|
# AST attribute restrictions (e.g., getattr(os, 'system')('...') avoids os.system check).
|
|
# User-defined functions and registered tools are allowed at runtime.
|
|
ALLOWED_BUILTINS: set[str] = {
|
|
"print",
|
|
"len",
|
|
"str",
|
|
"int",
|
|
"float",
|
|
"bool",
|
|
"list",
|
|
"dict",
|
|
"tuple",
|
|
"set",
|
|
"frozenset",
|
|
"range",
|
|
"enumerate",
|
|
"zip",
|
|
"map",
|
|
"filter",
|
|
"sorted",
|
|
"reversed",
|
|
"sum",
|
|
"min",
|
|
"max",
|
|
"abs",
|
|
"round",
|
|
"pow",
|
|
"divmod",
|
|
"all",
|
|
"any",
|
|
"chr",
|
|
"ord",
|
|
"hex",
|
|
"oct",
|
|
"bin",
|
|
"format",
|
|
"repr",
|
|
"ascii",
|
|
"bytes",
|
|
"bytearray",
|
|
"memoryview",
|
|
"isinstance",
|
|
"issubclass",
|
|
"callable",
|
|
"type",
|
|
"id",
|
|
"hash",
|
|
"next",
|
|
"iter",
|
|
"slice",
|
|
}
|
|
|
|
# Blocked builtin function names that expose dangerous capabilities.
|
|
BLOCKED_BUILTINS: set[str] = {
|
|
"eval",
|
|
"exec",
|
|
"compile",
|
|
"__import__",
|
|
"globals",
|
|
"locals",
|
|
"vars",
|
|
"dir",
|
|
"open", # File I/O must go through pathlib with explicit mounts
|
|
"input",
|
|
"help",
|
|
"breakpoint",
|
|
"exit",
|
|
"quit",
|
|
"copyright",
|
|
"credits",
|
|
"license",
|
|
"delattr",
|
|
"getattr", # Can bypass AST attribute checks: getattr(os, 'system')
|
|
"setattr", # Can bypass AST attribute checks
|
|
"hasattr", # Can probe for dangerous attributes
|
|
}
|
|
|
|
# Allowed AST node types for code structure and operations.
|
|
ALLOWED_AST_NODES: set[type[ast.AST]] = {
|
|
ast.Module,
|
|
ast.Expr,
|
|
ast.Assign,
|
|
ast.AugAssign,
|
|
ast.AnnAssign,
|
|
ast.For,
|
|
ast.AsyncFor,
|
|
ast.While,
|
|
ast.If,
|
|
ast.With,
|
|
ast.AsyncWith,
|
|
ast.Try,
|
|
ast.ExceptHandler,
|
|
ast.Pass,
|
|
ast.Break,
|
|
ast.Continue,
|
|
ast.Return,
|
|
ast.Await,
|
|
# Comparisons and boolean operations
|
|
ast.Compare,
|
|
ast.BoolOp,
|
|
ast.UnaryOp,
|
|
ast.And,
|
|
ast.Or,
|
|
ast.Not,
|
|
ast.Eq,
|
|
ast.NotEq,
|
|
ast.Lt,
|
|
ast.LtE,
|
|
ast.Gt,
|
|
ast.GtE,
|
|
ast.In,
|
|
ast.NotIn,
|
|
ast.Is,
|
|
ast.IsNot,
|
|
ast.UAdd,
|
|
ast.USub,
|
|
ast.Invert,
|
|
# Data access
|
|
ast.Name,
|
|
ast.Load,
|
|
ast.Store,
|
|
ast.Del,
|
|
ast.Attribute,
|
|
ast.Subscript,
|
|
ast.Slice,
|
|
# Literals
|
|
ast.Constant,
|
|
ast.List,
|
|
ast.Tuple,
|
|
ast.Set,
|
|
ast.Dict,
|
|
# Arithmetic and bitwise operations
|
|
ast.BinOp,
|
|
ast.Add,
|
|
ast.Sub,
|
|
ast.Mult,
|
|
ast.Div,
|
|
ast.Mod,
|
|
ast.FloorDiv,
|
|
ast.Pow,
|
|
ast.LShift,
|
|
ast.RShift,
|
|
ast.BitOr,
|
|
ast.BitXor,
|
|
ast.BitAnd,
|
|
# Function calls and comprehensions
|
|
ast.Call,
|
|
ast.keyword,
|
|
ast.ListComp,
|
|
ast.SetComp,
|
|
ast.DictComp,
|
|
ast.GeneratorExp,
|
|
ast.comprehension,
|
|
# Control flow helpers
|
|
ast.IfExp,
|
|
ast.JoinedStr,
|
|
ast.FormattedValue,
|
|
# Imports (validated separately)
|
|
ast.Import,
|
|
ast.ImportFrom,
|
|
ast.alias,
|
|
# Function definitions (for local helpers)
|
|
ast.FunctionDef,
|
|
ast.AsyncFunctionDef,
|
|
ast.arguments,
|
|
ast.arg,
|
|
# Lambda expressions
|
|
ast.Lambda,
|
|
# Match statements (Python 3.10+)
|
|
ast.Match,
|
|
ast.match_case,
|
|
ast.MatchValue,
|
|
ast.MatchSingleton,
|
|
ast.MatchSequence,
|
|
ast.MatchMapping,
|
|
ast.MatchClass,
|
|
ast.MatchStar,
|
|
ast.MatchAs,
|
|
ast.MatchOr,
|
|
# Starred expressions
|
|
ast.Starred,
|
|
}
|
|
|
|
|
|
class CodeValidationError(ValueError):
|
|
"""Raised when generated code violates the allow-list policy."""
|
|
|
|
pass
|
|
|
|
|
|
class _CodeValidator(ast.NodeVisitor):
|
|
"""AST visitor that validates generated code against allow-lists."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
allowed_imports: set[str] | None = None,
|
|
blocked_imports: set[str] | None = None,
|
|
allowed_builtins: set[str] | None = None,
|
|
blocked_builtins: set[str] | None = None,
|
|
allowed_os_attrs: set[str] | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
self._errors: list[str] = []
|
|
self._allowed_imports = allowed_imports if allowed_imports is not None else ALLOWED_IMPORTS
|
|
self._blocked_imports = blocked_imports if blocked_imports is not None else BLOCKED_IMPORTS
|
|
self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS
|
|
self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS
|
|
self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS
|
|
|
|
def validate(self, code: str) -> None:
|
|
"""Validate code and raise CodeValidationError if it violates policy."""
|
|
try:
|
|
tree = ast.parse(code, mode="exec")
|
|
except SyntaxError as exc:
|
|
raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc
|
|
|
|
self._errors = []
|
|
self.visit(tree)
|
|
|
|
if self._errors:
|
|
raise CodeValidationError(
|
|
"Generated code violates allow-list policy:\n" + "\n".join(f"- {err}" for err in self._errors)
|
|
)
|
|
|
|
def visit(self, node: ast.AST) -> Any:
|
|
"""Visit a node and check if its type is allowed."""
|
|
node_type = type(node)
|
|
if node_type not in ALLOWED_AST_NODES:
|
|
self._errors.append(f"AST node type '{node_type.__name__}' is not allowed")
|
|
return None
|
|
return super().visit(node)
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
"""Validate import statements."""
|
|
for alias_node in node.names:
|
|
module_name = alias_node.name.split(".")[0]
|
|
if module_name in self._blocked_imports:
|
|
self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})")
|
|
elif module_name not in self._allowed_imports:
|
|
self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)")
|
|
self.generic_visit(node)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
"""Validate from-import statements."""
|
|
if node.module is None:
|
|
self._errors.append("Relative imports are not allowed")
|
|
return
|
|
|
|
module_name = node.module.split(".")[0]
|
|
if module_name in self._blocked_imports:
|
|
self._errors.append(f"Import from '{node.module}' is not allowed (blocked: {module_name})")
|
|
elif module_name not in self._allowed_imports:
|
|
self._errors.append(f"Import from '{node.module}' is not allowed (not in allow-list)")
|
|
elif module_name == "os":
|
|
# Mirror the os.* attribute allow-list for ``from os import X``,
|
|
# otherwise ``from os import system`` would bypass visit_Attribute.
|
|
for alias_node in node.names:
|
|
if alias_node.name not in self._allowed_os_attrs:
|
|
self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed")
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
"""Validate function calls.
|
|
|
|
For names that match a real Python builtin we enforce both the block-list
|
|
and the allow-list. Names that are not builtins are treated as user-defined
|
|
functions or registered tools and are allowed (validated at runtime).
|
|
"""
|
|
if isinstance(node.func, ast.Name):
|
|
func_name = node.func.id
|
|
if func_name in self._blocked_builtins:
|
|
self._errors.append(f"Call to builtin '{func_name}' is not allowed")
|
|
elif func_name in _PYTHON_BUILTIN_NAMES and func_name not in self._allowed_builtins:
|
|
# Real builtin that wasn't explicitly allowed — reject so the allow-list is meaningful.
|
|
self._errors.append(f"Call to builtin '{func_name}' is not in the allowed builtins list")
|
|
|
|
# Check for attribute access to dangerous methods
|
|
if isinstance(node.func, ast.Attribute):
|
|
attr_name = node.func.attr
|
|
# Block common dangerous attribute methods
|
|
if (
|
|
attr_name.startswith("__")
|
|
and attr_name.endswith("__")
|
|
and attr_name not in {"__init__", "__str__", "__repr__", "__eq__", "__hash__"}
|
|
):
|
|
self._errors.append(f"Call to dunder method '{attr_name}' is not allowed")
|
|
|
|
self.generic_visit(node)
|
|
|
|
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
"""Validate attribute access."""
|
|
# Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS`
|
|
# (file I/O, process control, mutating helpers, etc.) is rejected so the
|
|
# validator matches the documented `os.environ` / `os.path`-only contract.
|
|
if isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr not in self._allowed_os_attrs:
|
|
self._errors.append(f"Access to os.{node.attr} is not allowed")
|
|
|
|
# Block access to certain dangerous attributes
|
|
if (
|
|
node.attr.startswith("__")
|
|
and node.attr.endswith("__")
|
|
and node.attr
|
|
not in {
|
|
"__name__",
|
|
"__doc__",
|
|
"__dict__",
|
|
"__class__",
|
|
"__module__",
|
|
"__file__",
|
|
"__init__",
|
|
"__str__",
|
|
"__repr__",
|
|
"__eq__",
|
|
"__hash__",
|
|
"__len__",
|
|
"__iter__",
|
|
"__next__",
|
|
"__enter__",
|
|
"__exit__",
|
|
"__aenter__",
|
|
"__aexit__",
|
|
}
|
|
):
|
|
self._errors.append(f"Access to attribute '{node.attr}' is not allowed")
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
|
def validate_code(
|
|
code: str,
|
|
*,
|
|
allowed_imports: set[str] | None = None,
|
|
blocked_imports: set[str] | None = None,
|
|
allowed_builtins: set[str] | None = None,
|
|
blocked_builtins: set[str] | None = None,
|
|
allowed_os_attrs: set[str] | None = None,
|
|
) -> None:
|
|
"""Validate generated code against AST allow-lists.
|
|
|
|
Args:
|
|
code: Python source code to validate.
|
|
allowed_imports: Custom set of allowed module names (replaces defaults).
|
|
blocked_imports: Custom set of blocked module names (replaces defaults).
|
|
allowed_builtins: Custom set of allowed builtin names (replaces defaults).
|
|
blocked_builtins: Custom set of blocked builtin names (replaces defaults).
|
|
allowed_os_attrs: Custom set of allowed ``os`` attribute names
|
|
(replaces the default ``{"environ", "path"}`` allow-list).
|
|
|
|
Raises:
|
|
CodeValidationError: If the code violates the allow-list policy.
|
|
"""
|
|
validator = _CodeValidator(
|
|
allowed_imports=allowed_imports,
|
|
blocked_imports=blocked_imports,
|
|
allowed_builtins=allowed_builtins,
|
|
blocked_builtins=blocked_builtins,
|
|
allowed_os_attrs=allowed_os_attrs,
|
|
)
|
|
validator.validate(code)
|
|
|
|
|
|
def _main() -> int:
|
|
"""Script entrypoint: read a JSON request from stdin and validate it.
|
|
|
|
Request shape:
|
|
{
|
|
"code": "...",
|
|
"allowed_imports": [...]?,
|
|
"blocked_imports": [...]?,
|
|
"allowed_builtins": [...]?,
|
|
"blocked_builtins": [...]?,
|
|
"allowed_os_attrs": [...]?
|
|
}
|
|
|
|
On success: exit code 0, no output required.
|
|
On validation failure: exit code 1, JSON {"errors": ["..."]} on stdout.
|
|
On request error: exit code 2, JSON {"message": "..."} on stdout.
|
|
"""
|
|
import json
|
|
import sys
|
|
|
|
raw = sys.stdin.read()
|
|
try:
|
|
request = json.loads(raw) if raw.strip() else {}
|
|
if not isinstance(request, dict):
|
|
raise ValueError("Validator request must be a JSON object.")
|
|
code = request.get("code")
|
|
if not isinstance(code, str):
|
|
raise ValueError("Validator request must include a 'code' string field.")
|
|
except Exception as exc: # noqa: BLE001 - report any parse error to caller
|
|
json.dump({"message": f"Invalid validator request: {exc}"}, sys.stdout)
|
|
return 2
|
|
|
|
def _as_set(value: Any) -> set[str] | None:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, list):
|
|
raise ValueError("Validator allow/block lists must be arrays of strings.")
|
|
return {str(item) for item in value}
|
|
|
|
try:
|
|
validate_code(
|
|
code,
|
|
allowed_imports=_as_set(request.get("allowed_imports")),
|
|
blocked_imports=_as_set(request.get("blocked_imports")),
|
|
allowed_builtins=_as_set(request.get("allowed_builtins")),
|
|
blocked_builtins=_as_set(request.get("blocked_builtins")),
|
|
allowed_os_attrs=_as_set(request.get("allowed_os_attrs")),
|
|
)
|
|
except CodeValidationError as exc:
|
|
message = str(exc)
|
|
lines = [line.lstrip("- ").rstrip() for line in message.splitlines() if line.strip()]
|
|
if lines and lines[0].startswith("Generated code violates"):
|
|
lines = lines[1:]
|
|
if not lines:
|
|
lines = [message]
|
|
json.dump({"errors": lines}, sys.stdout)
|
|
return 1
|
|
except Exception as exc: # noqa: BLE001 - convert unexpected errors to a structured response
|
|
json.dump({"errors": [f"{type(exc).__name__}: {exc}"]}, sys.stdout)
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_main())
|