From 482ff13dd3df84d77d1da312988303341e82b041 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 28 Sep 2026 14:00:43 +0545 Subject: [PATCH] fix: replace eval() with an AST parser for template expressions Empty __builtins__ is not a sandbox, so a template expression could reach os.system. Walk the AST with an allow-list instead. Also fixes app names missing from api/v1/urls.py and a wrong settings path in the generated pyproject.toml. --- src/djinit/core/parser.py | 409 +++++++++++++++++- src/djinit/creators/files.py | 20 + src/djinit/creators/setup.py | 3 + .../templates/project/pyproject.toml-tpl | 6 +- src/djinit/templates/project/readme.md-tpl | 10 + 5 files changed, 428 insertions(+), 20 deletions(-) diff --git a/src/djinit/core/parser.py b/src/djinit/core/parser.py index f672e23..d02ad97 100644 --- a/src/djinit/core/parser.py +++ b/src/djinit/core/parser.py @@ -1,5 +1,375 @@ +""" +Safe template expression evaluation for djinit. + +Templates use a tiny, comment-style logic language: + + # @IF ... # @ELSEIF ... # @ELSE ... # @ENDIF + # @LOOP in ... # @ENDLOOP + [[ variable ]] variable / attribute / index substitution + +Expressions are evaluated by walking the parsed syntax tree with an explicit +allow-list. ``eval()`` is never used: restricting ``__builtins__`` is *not* a +sandbox, because object graphs reachable from ordinary literals (for example +``().__class__.__base__.__subclasses__()``) still hand out ``os.system`` and +friends. An explicit interpreter has no such escape hatch. +""" + +import ast +import operator import re -from typing import Any, Dict, List +from collections.abc import Mapping +from typing import Any, Dict, List, Optional + +__all__ = ["InFileLogicParser", "SafeExpressionError"] + + +class SafeExpressionError(ValueError): + """Raised when a template expression is malformed or uses a disallowed feature.""" + + +# The only names an expression may reference besides the render context. These are +# side-effect free helpers; nothing that can import, open, execute or mutate. +_SAFE_BUILTINS: Mapping[str, Any] = { + "True": True, + "False": False, + "None": None, + "abs": abs, + "all": all, + "any": any, + "bool": bool, + "dict": dict, + "enumerate": enumerate, + "float": float, + "int": int, + "len": len, + "list": list, + "max": max, + "min": min, + "reversed": reversed, + "sorted": sorted, + "str": str, + "zip": zip, +} + +# Attribute names that must never be reachable, even on an otherwise harmless +# object. Together with rejecting every ``_``-prefixed name this closes the usual +# attribute-hop escape routes (``__class__``, ``__globals__``, ``__subclasses__``…). +# ``format``/``format_map`` are included because the *format string* they receive is +# resolved with ``getattr`` inside CPython, which would sidestep the checks below. +_FORBIDDEN_ATTRIBUTES = frozenset( + { + "__import__", + "breakpoint", + "compile", + "delattr", + "eval", + "exec", + "exit", + "format", + "format_map", + "fork", + "getattr", + "globals", + "input", + "locals", + "open", + "popen", + "quit", + "setattr", + "spawn", + "system", + "vars", + } +) + +# Calling a method is only allowed on plain data values. Without this, a context +# value that happens to be a module, class or function would expose its whole +# (dangerous) surface through dotted access. +_PLAIN_DATA_TYPES = (str, bytes, bytearray, bool, int, float, complex, list, tuple, dict, set, frozenset, Mapping) + +# Guards against cheap resource exhaustion such as ``2 ** 99999999``. +_MAX_EXPONENT = 1000 + + +class _SafeExpressionEvaluator(ast.NodeVisitor): + """Interprets a validated expression tree against the render context.""" + + _BINARY_OPERATORS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, + } + + _UNARY_OPERATORS = { + ast.UAdd: operator.pos, + ast.USub: operator.neg, + ast.Not: operator.not_, + ast.Invert: operator.invert, + } + + _COMPARISON_OPERATORS = { + ast.Eq: operator.eq, + ast.NotEq: operator.ne, + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, + ast.Is: operator.is_, + ast.IsNot: operator.is_not, + ast.In: lambda a, b: operator.contains(b, a), + ast.NotIn: lambda a, b: not operator.contains(b, a), + } + + def __init__(self, context: Mapping[str, Any]) -> None: + # Outermost scope is the render context; comprehension bodies push their own + # dicts so loop variables never leak into the surrounding scope. + self._scopes: List[Any] = [context] + + # -- entry point --------------------------------------------------------- + + def evaluate(self, tree: ast.Expression) -> Any: + return self.visit(tree.body) + + def generic_visit(self, node: ast.AST) -> Any: + raise SafeExpressionError(f"{type(node).__name__} is not allowed in template expressions") + + # -- literals ------------------------------------------------------------ + + def visit_Constant(self, node: ast.Constant) -> Any: + return node.value + + def visit_List(self, node: ast.List) -> list: + return [self.visit(element) for element in node.elts] + + def visit_Tuple(self, node: ast.Tuple) -> tuple: + return tuple(self.visit(element) for element in node.elts) + + def visit_Set(self, node: ast.Set) -> set: + return {self.visit(element) for element in node.elts} + + def visit_Dict(self, node: ast.Dict) -> dict: + result: dict = {} + for key_node, value_node in zip(node.keys, node.values): + if key_node is None: + raise SafeExpressionError("Dictionary unpacking is not allowed") + result[self.visit(key_node)] = self.visit(value_node) + return result + + def visit_JoinedStr(self, node: ast.JoinedStr) -> str: + return "".join(str(self.visit(value)) for value in node.values) + + def visit_FormattedValue(self, node: ast.FormattedValue) -> str: + value = self.visit(node.value) + if node.conversion == 115: # !s + return f"{value:s}" + if node.conversion == 114: # !r + return repr(value) + if node.conversion == 97: # !a + return ascii(value) + if node.format_spec is not None: + return format(value, self.visit(node.format_spec)) + return str(value) + + # -- names and attributes ------------------------------------------------ + + def visit_Name(self, node: ast.Name) -> Any: + return self._lookup(node.id) + + def _lookup(self, name: str) -> Any: + for scope in reversed(self._scopes): + if name in scope: + return scope[name] + if name in _SAFE_BUILTINS: + return _SAFE_BUILTINS[name] + raise SafeExpressionError(f"Unknown name: {name!r}") + + def visit_Attribute(self, node: ast.Attribute) -> Any: + return self._resolve_attribute(self.visit(node.value), node.attr) + + @staticmethod + def _resolve_attribute(value: Any, name: str) -> Any: + if name.startswith("_") or name in _FORBIDDEN_ATTRIBUTES: + raise SafeExpressionError(f"Access to attribute {name!r} is not allowed") + if isinstance(value, Mapping): + # Templates routinely index plain dicts with dotted syntax. + try: + return value[name] + except KeyError: + raise AttributeError(name) from None + resolved = getattr(value, name) + if callable(resolved) and not isinstance(value, _PLAIN_DATA_TYPES): + raise SafeExpressionError(f"Access to method {name!r} is not allowed here") + return resolved + + # -- operators ----------------------------------------------------------- + + def visit_BoolOp(self, node: ast.BoolOp) -> Any: + if isinstance(node.op, ast.And): + result: Any = True + for value in node.values: + result = self.visit(value) + if not result: + return result + return result + result = False + for value in node.values: + result = self.visit(value) + if result: + return result + return result + + def visit_UnaryOp(self, node: ast.UnaryOp) -> Any: + handler = self._UNARY_OPERATORS.get(type(node.op)) + if handler is None: + raise SafeExpressionError(f"Unary operator {type(node.op).__name__} is not allowed") + return handler(self.visit(node.operand)) + + def visit_BinOp(self, node: ast.BinOp) -> Any: + handler = self._BINARY_OPERATORS.get(type(node.op)) + if handler is None: + raise SafeExpressionError(f"Binary operator {type(node.op).__name__} is not allowed") + left = self.visit(node.left) + right = self.visit(node.right) + if isinstance(node.op, ast.Pow) and isinstance(left, int) and isinstance(right, int): + if abs(right) > _MAX_EXPONENT: + raise SafeExpressionError("Exponent is too large") + return handler(left, right) + + def visit_Compare(self, node: ast.Compare) -> bool: + left = self.visit(node.left) + for operator_node, comparator_node in zip(node.ops, node.comparators): + handler = self._COMPARISON_OPERATORS.get(type(operator_node)) + if handler is None: + raise SafeExpressionError(f"Comparison {type(operator_node).__name__} is not allowed") + right = self.visit(comparator_node) + if not handler(left, right): + return False + left = right + return True + + def visit_IfExp(self, node: ast.IfExp) -> Any: + return self.visit(node.body) if self.visit(node.test) else self.visit(node.orelse) + + # -- subscripts ---------------------------------------------------------- + + def visit_Subscript(self, node: ast.Subscript) -> Any: + value = self.visit(node.value) + key = self._resolve_subscript_key(node.slice, value) + try: + return value[key] + except TypeError as exc: + raise SafeExpressionError(f"Cannot subscript {type(value).__name__}") from exc + + def _resolve_subscript_key(self, node: ast.AST, value: Any) -> Any: + if isinstance(node, ast.Slice): + return slice( + self._resolve_subscript_bound(node.lower), + self._resolve_subscript_bound(node.upper), + self._resolve_subscript_bound(node.step), + ) + if isinstance(node, ast.Tuple): + return tuple(self.visit(element) for element in node.elts) + return self.visit(node) + + def _resolve_subscript_bound(self, node: Optional[ast.AST]) -> Optional[Any]: + return None if node is None else self.visit(node) + + # -- calls --------------------------------------------------------------- + + def visit_Call(self, node: ast.Call) -> Any: + if any(keyword.arg is None for keyword in node.keywords): + raise SafeExpressionError("** unpacking is not allowed in calls") + args = [self.visit(argument) for argument in node.args] + kwargs = {keyword.arg: self.visit(keyword.value) for keyword in node.keywords} + callee = self.visit(node.func) + if not callable(callee): + raise SafeExpressionError(f"{callee!r} is not callable") + return callee(*args, **kwargs) + + # -- comprehensions ------------------------------------------------------ + + def visit_ListComp(self, node: ast.ListComp) -> list: + result: list = [] + self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt))) + return result + + def visit_SetComp(self, node: ast.SetComp) -> set: + result: set = set() + self._run_comprehension(node.generators, 0, lambda: result.add(self.visit(node.elt))) + return result + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> tuple: + result: list = [] + self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt))) + return tuple(result) + + def visit_DictComp(self, node: ast.DictComp) -> dict: + result: dict = {} + self._run_comprehension( + node.generators, + 0, + lambda: result.__setitem__(self.visit(node.key), self.visit(node.value)), + ) + return result + + def _run_comprehension(self, generators: List[ast.comprehension], index: int, emit) -> None: + if index >= len(generators): + emit() + return + + generator = generators[index] + for value in self._as_iterable(self.visit(generator.iter)): + self._scopes.append({}) + try: + self._assign(generator.target, value) + if all(self.visit(condition) for condition in generator.ifs): + self._run_comprehension(generators, index + 1, emit) + finally: + self._scopes.pop() + + @staticmethod + def _as_iterable(value: Any) -> Any: + if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"): + raise SafeExpressionError(f"{type(value).__name__} is not iterable") + return value + + def _assign(self, target: ast.AST, value: Any) -> None: + if isinstance(target, ast.Name): + self._scopes[-1][target.id] = value + return + if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (tuple, list)): + if len(target.elts) != len(value): + raise SafeExpressionError("Cannot unpack mismatched number of loop variables") + for element, item in zip(target.elts, value): + self._assign(element, item) + return + raise SafeExpressionError("Unsupported loop target") + + +def safe_eval(expression: str, context: Optional[Mapping[str, Any]] = None) -> Any: + """Evaluate a template expression without ``eval()``. + + Args: + expression: The expression source, e.g. ``"use_tailwind and len(app_names) > 0"``. + context: Names available to the expression. + + Raises: + SafeExpressionError: If the expression is empty, malformed or disallowed. + """ + source = expression.strip() + if not source: + raise SafeExpressionError("Empty expression") + + try: + tree = ast.parse(source, mode="eval") + except SyntaxError as exc: + raise SafeExpressionError(f"Invalid expression syntax: {source!r}") from exc + + return _SafeExpressionEvaluator(context or {}).evaluate(tree) class InFileLogicParser: @@ -11,18 +381,21 @@ class InFileLogicParser: def __init__(self, context: Dict[str, Any] = None): self.context = context or {} + def _evaluate(self, expression: str) -> Any: + """Evaluate ``expression`` against the current context, safely.""" + return safe_eval(expression, self.context) + def _get_value(self, key: str) -> str: """ Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]]. - For simplicity, it uses eval in the provided context for complex expressions, - but falls back to safe dictionary lookup for simple keys. + Dotted names are resolved against the context, and arbitrary expressions are + evaluated by the safe interpreter. Anything that cannot be resolved is + returned verbatim so a missing context key stays visible in the output. """ key = key.strip() try: - # Try evaluating the expression in the context - return str(eval(key, {"__builtins__": {}}, self.context)) - except (NameError, SyntaxError, KeyError, TypeError, AttributeError): - # Fallback for common patterns or just return the key as is if it fails + return str(self._evaluate(key)) + except (AttributeError, IndexError, KeyError, TypeError, ValueError): return f"[[ {key} ]]" def render(self, template_text: str, context: Dict[str, Any] = None) -> str: @@ -44,8 +417,8 @@ class InFileLogicParser: if stripped.startswith("# @IF "): expr = stripped[6:].strip() try: - result = bool(eval(expr, {"__builtins__": {}}, self.context)) - except Exception: + result = bool(self._evaluate(expr)) + except (AttributeError, IndexError, KeyError, TypeError, ValueError): result = False stack.append([result, result]) i += 1 @@ -65,8 +438,8 @@ class InFileLogicParser: current_stack[0] = False else: try: - result = bool(eval(expr, {"__builtins__": {}}, self.context)) - except Exception: + result = bool(self._evaluate(expr)) + except (AttributeError, IndexError, KeyError, TypeError, ValueError): result = False current_stack[0] = result if result: @@ -113,13 +486,15 @@ class InFileLogicParser: i += 1 continue - parts = stripped[7:].strip().split(" in ") - if len(parts) == 2: + parts = stripped[7:].strip().split(" in ", 1) + if len(parts) == 2 and parts[0].strip() and parts[1].strip(): var_name = parts[0].strip() iterable_name = parts[1].strip() try: - iterable = eval(iterable_name, {"__builtins__": {}}, self.context) - except Exception: + iterable = self._evaluate(iterable_name) + if not hasattr(iterable, "__iter__"): + raise TypeError(f"{iterable_name!r} is not iterable") + except (AttributeError, IndexError, KeyError, TypeError, ValueError): iterable = [] # Capture loop body @@ -197,14 +572,14 @@ class InFileLogicParser: post_content = expr_parts[1].lstrip() try: - if bool(eval(expr, {"__builtins__": {}}, self.context)): + if bool(self._evaluate(expr)): # If Case 1, we want pre_content. If Case 2, we want post_content. # Usually, if pre_content is empty (or just whitespace), it's Case 2. if pre_content: final_lines.append(pre_content + post_content) else: final_lines.append(post_content) - except Exception: + except (AttributeError, IndexError, KeyError, TypeError, ValueError): pass i += 1 continue diff --git a/src/djinit/creators/files.py b/src/djinit/creators/files.py index dbeaa91..5550297 100644 --- a/src/djinit/creators/files.py +++ b/src/djinit/creators/files.py @@ -8,6 +8,7 @@ import os from djinit.core.base import BaseService from djinit.templater import template_engine from djinit.utils.common import CommonUtils +from djinit.utils.django import DjangoHelper class FileCreator(BaseService): @@ -195,12 +196,26 @@ class FileCreator(BaseService): "Created requirements.txt with Django dependencies", ) + def _registered_app_modules(self) -> list: + """Dotted module paths of the apps that end up in INSTALLED_APPS. + + The unified and single layouts use the generated package itself as their + only app, so they have no separate ``app_names`` to report. + """ + if self.metadata.get("unified_structure"): + return ["apps"] + if self.metadata.get("single_structure"): + return [self.module_name] + return CommonUtils.calculate_app_module_paths(self.app_names, self.metadata) + def create_readme(self) -> None: context = { "project_name": self.project_name, "app_names": self.app_names, + "app_list": self._registered_app_modules(), "predefined_structure": bool(self.metadata.get("predefined_structure")), "unified_structure": bool(self.metadata.get("unified_structure")), + "single_structure": bool(self.metadata.get("single_structure")), "module_name": self.module_name, } self._render_and_create_file("README.md", "project/readme.md-tpl", context, "Created README.md file") @@ -293,6 +308,11 @@ class FileCreator(BaseService): "Created apps/core/base.py", ) + # Every app that ends up in USER_DEFINED_APPS needs a real app module, + # otherwise the generated project cannot start. + for app_name in self.app_names or ["users", "core"]: + DjangoHelper.startapp(app_name, apps_dir, f"apps.{app_name}") + # Create api directory structure api_dir = os.path.join(self.project_root, "api") CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py") diff --git a/src/djinit/creators/setup.py b/src/djinit/creators/setup.py index 0c970a4..9b740b6 100644 --- a/src/djinit/creators/setup.py +++ b/src/djinit/creators/setup.py @@ -35,6 +35,7 @@ class SetupCreator: if not self.app_names: self.app_names = ["users", "core"] self.project_creator.app_names = self.app_names + self.file_creator.app_names = self.app_names elif self.metadata.get("unified_structure"): self.metadata.setdefault("project_module_name", "core") @@ -43,12 +44,14 @@ class SetupCreator: if not self.app_names: self.app_names = [] self.project_creator.app_names = self.app_names + self.file_creator.app_names = self.app_names elif self.metadata.get("single_structure"): self.metadata.setdefault("nested_apps", False) if not self.app_names: self.app_names = [] self.project_creator.app_names = self.app_names + self.file_creator.app_names = self.app_names def create(self) -> bool: """Run the full setup process.""" diff --git a/src/djinit/templates/project/pyproject.toml-tpl b/src/djinit/templates/project/pyproject.toml-tpl index 7797bb0..96b512b 100644 --- a/src/djinit/templates/project/pyproject.toml-tpl +++ b/src/djinit/templates/project/pyproject.toml-tpl @@ -1,5 +1,5 @@ [project] -name = "[[ project_name ]]" +name = "[[ package_name ]]" version = "0.1.0" description = "" readme = "README.md" @@ -61,7 +61,7 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["[[ project_name ]]"] +known-first-party = ["[[ module_name ]]"] [tool.ruff.lint.per-file-ignores] -"[[ project_name ]]/settings/*.py" = ["F403", "F405"] +"[[ module_name ]]/settings/*.py" = ["F403", "F405"] diff --git a/src/djinit/templates/project/readme.md-tpl b/src/djinit/templates/project/readme.md-tpl index 9c28632..571c29d 100644 --- a/src/djinit/templates/project/readme.md-tpl +++ b/src/djinit/templates/project/readme.md-tpl @@ -9,6 +9,16 @@ A Django project folder structure is generated with [djinitx/djinit/dj](https:// - Essential dependencies and utilities - Production-ready configuration +# @IF app_list +## Apps + +Registered in `[[ module_name ]]/settings/base.py`: + +# @LOOP app in app_list +- `[[ app ]]` +# @ENDLOOP + +# @ENDIF ## Setup ### Using Just (Recommended)