evalidate-2.1.7/setup.py0000644000000000000000000000206513615410400012114 0ustar00from setuptools import setup from importlib.machinery import SourceFileLoader import os def read(fname): return open(os.path.join(os.path.dirname(__file__), fname), encoding='utf-8').read() def get_version(path): foo = SourceFileLoader(os.path.basename(path), path).load_module() return foo.__version__ setup(name='evalidate', version=get_version('evalidate/__init__.py'), url='http://github.com/yaroslaff/evalidate', author='Yaroslav Polyakov', author_email='xenon@sysattack.com', license='MIT', packages=['evalidate'], description='Validation and secure evaluation of untrusted python expressions', long_description=read('README.md'), long_description_content_type='text/markdown', classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", 'Topic :: Utilities', "Topic :: Security", "Topic :: Software Development :: Interpreters", ], zip_safe=False) evalidate-2.1.7/benchmark/benchmark.py0000755000000000000000000000426713615410400014651 0ustar00#!/usr/bin/env python3 from asteval import Interpreter import timeit import evalidate import requests import simpleeval import json from pprint import pprint # context = {'a':1, 'b': 2} # src="""a+b""" # node = evalidate.evalidate(src) # code = compile(node, '', 'eval') """ Our test: we prepare large list of products and filter it, find all items cheaper then 20 """ mult = 10000 # mult = 1 products = requests.get('https://dummyjson.com/products?limit=100').json()['products'] * mult accurate_counter = 8 * mult # gi.symtable = context def test_asteval(): aeval = Interpreter() src = """ counter=0 for p in products: if p['price']<20: counter += 1 counter """ aeval.symtable['products'] = products result = aeval(src) assert(result == accurate_counter) def test_simpleeval(): code = """price < 20""" s = simpleeval.SimpleEval() parsed = s.parse(code) counter = 0 for p in products: s.names = p if s.eval(code, previously_parsed=parsed, ): counter += 1 assert(counter == accurate_counter) def evalidate_raw_eval(): src="""price < 20""" e = evalidate.Expr(src) counter = 0 for p in products: if eval(e.code, None, p): counter+=1 assert(counter == accurate_counter) def evalidate_eval(): src="""price < 20""" e = evalidate.Expr(src) counter = 0 for p in products: if e.eval(p): counter+=1 assert(counter == accurate_counter) def main(): print(f"Products: {len(products)} items") t = timeit.timeit('evalidate_raw_eval()', setup='from __main__ import evalidate_raw_eval, products', number=1) print(f"evalidate_raw_eval(): {t:.3f}s") t = timeit.timeit('evalidate_eval()', setup='from __main__ import evalidate_eval, products', number=1) print(f"evalidate_eval(): {t:.3f}s") t = timeit.timeit('test_simpleeval()', setup='from __main__ import test_simpleeval, products', number=1) print(f"test_simpleeval(): {t:.3f}s") t = timeit.timeit('test_asteval()', setup='from __main__ import test_asteval, products', number=1) print(f"test_asteval(): {t:.3f}s") if __name__ == '__main__': main()evalidate-2.1.7/evalidate/__init__.py0000755000000000000000000001157213615410400014457 0ustar00#!/usr/bin/python """Safe user-supplied python expression evaluation.""" import ast import dataclasses from typing import Callable __version__ = '2.1.7' class EvalException(Exception): pass class ValidationException(EvalException): pass class CompilationException(EvalException): exc = None def __init__(self, exc): super().__init__(exc) self.exc = exc class ExecutionException(EvalException): exc = None def __init__(self, exc): super().__init__(exc) self.exc = exc @dataclasses.dataclass class EvalModel: """ eval security model """ nodes: list = dataclasses.field(default_factory=list) allowed_functions: list = dataclasses.field(default_factory=list) imported_functions: dict = dataclasses.field(default_factory=dict) attributes: list = dataclasses.field(default_factory=list) def clone(self): return EvalModel(**dataclasses.asdict(self)) def test_security(self, attacks=None, verbose=False): from .security import test_security return test_security(attacks=attacks, model=self, verbose=verbose) def expr(self, expr): return Expr(expr=expr, model=self) class SafeAST(ast.NodeVisitor): """AST-tree walker class.""" def __init__(self, model: EvalModel): self.model = model def generic_visit(self, node): """Check node, raise exception if node is not in whitelist.""" if type(node).__name__ in self.model.nodes: if isinstance(node, ast.Attribute): if node.attr not in self.model.attributes: raise ValidationException( "Attribute {aname} is not allowed".format( aname=node.attr)) if isinstance(node, ast.Call): if isinstance(node.func, ast.Name): if node.func.id not in self.model.allowed_functions and node.func.id not in self.model.imported_functions: raise ValidationException( "Call to function {fname}() is not allowed".format( fname=node.func.id)) else: # Call to allowed function. good. No exception pass elif isinstance(node.func, ast.Attribute): pass # print("attr:", node.func.attr) else: raise ValidationException('Indirect function call') ast.NodeVisitor.generic_visit(self, node) else: raise ValidationException( "Node type {optype!r} is not allowed. (whitelist it manually)".format( optype=type(node).__name__)) base_eval_model = EvalModel( nodes = [ # 123, 'asdf' 'Num', 'Str', # any expression or constant 'Expression', 'Constant', # == ... 'Compare', 'Eq', 'NotEq', 'Gt', 'GtE', 'Lt', 'LtE', # variable name 'Name', 'Load', 'BinOp', 'Add', 'Sub', 'USub', 'Subscript', 'Index', # person['name'] 'BoolOp', 'And', 'Or', 'UnaryOp', 'Not', # True and True "In", "NotIn", # "aaa" in i['list'] "IfExp", # for if expressions, like: expr1 if expr2 else expr3 "NameConstant", # for True and False constants "Div", "Mod", "Is", "IsNot" ], ) mult_eval_model = base_eval_model.clone() mult_eval_model.nodes.append('Mul') class Expr(): def __init__(self, expr, model=None, filename=None): self.expr = expr self.model = model or base_eval_model try: self.node = ast.parse(self.expr, '', 'eval') except SyntaxError as e: raise CompilationException(e) v = SafeAST(model = self.model) v.visit(self.node) self.code = compile(self.node, filename or '', 'eval') def eval(self, ctx_globals=None, ctx_locals=None): # # PEP709 (python 3.12 and above): list comprehensions works even with data from locals # before 3.12, list comprehensions works only with data in globals # ctx_locals = dict() if ctx_locals is None else ctx_locals ctx_globals = dict() if ctx_globals is None else ctx_globals if ctx_globals: ctx_globals = { **self.model.imported_functions, **ctx_globals } else: ctx_globals = self.model.imported_functions try: result = eval(self.code, ctx_globals, ctx_locals) except Exception as e: raise ExecutionException(e) return result def __str__(self): return("Expr(expr={expr!r})".format(expr=self.expr)) evalidate-2.1.7/evalidate/credits.txt0000644000000000000000000000025713615410400014537 0ustar00Yaroslav Polyakov: main author Daniel Rech: python3 porting, ideas for IfExp function call filtering Nagizaaz Shaik: .format() and .format_map() attacks for security_check()evalidate-2.1.7/evalidate/security.py0000644000000000000000000000550413615410400014562 0ustar00from evalidate import ValidationException, Expr, EvalModel, base_eval_model SECRET = '>>> this is a secret <<<' def one(): return 1 simple_attacks = [ """ os.system("clear") """, """ __import__('os').system('clear') """, """ __builtins__['eval']("print(1)") """, # information disclosure attacks '"{SECRET}".format_map(globals())', '"{one.__globals__[SECRET]}".format_map(locals())', '"{one.__globals__[SECRET]}".format_map(globals())', '"{one.__globals__[SECRET]}".format_map({"one": one})', '"{0.__globals__[SECRET]}".format(one)', # resource exhaustion attacks '10 ** 10 ** 10' ] boom = """ (lambda fc=( lambda n: [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n ][0] ): fc("function")( fc("code")( {payload} ),{{}} )() )() """ boom_payload = [ # 2.7: '0,0,0,0,"BOOM",(),(),(),"","",0,""', # 3.5-3.7: '0,0,0,0,0,b"BOOM",(),(),(),"","",0,b""', # 3.8-3.10: '0,0,0,0,0,0,b"BOOM",(),(),(),"","",0,b""', # 3.11: '0,0,0,0,0,0,b"BOOM",(),(),(),"","","",0,b"",b"",b"",b"",(),()' ] def test_attack(attack: str, model: EvalModel, verbose=False): ''' test attack. return True if attack detected on validation, good. (False if passed, bad) ''' if verbose: print("Testing attack code:\n{}".format(attack)) try: e = Expr(attack, model=model) # node = evalidate(attack, safenodes=safenodes, addnodes=addnodes, funcs=funcs, attrs=attrs) except ValidationException as e: if verbose: print("Good! Attack blocked: {}".format(e)) return True else: print("Problem! Attack passed validation without exception!\nCode:\n{}".format(attack)) return False def test_security(attacks=None, model: EvalModel = None, verbose=False): ''' test all user-given attacks, or built-in attacks. Return value: True if good (all attacks detected), False if at least one attack passes validation ''' model = model or base_eval_model # test user-supplied attacks if attacks: return all( [test_attack(attack=attack, model=model, verbose=verbose) for attack in attacks] ) # test built-in set of attacks if not all([test_attack(attack=attack, model=model, verbose=verbose) for attack in simple_attacks]): return False for payload in boom_payload: attack = boom.format(payload=payload) if not test_attack(attack=attack, model=model, verbose=verbose): return False return True if __name__ == '__main__': attacks = [ '1*2' ] model = base_eval_model.clone() # model.nodes.append('Mult') test_security(attacks=attacks, model=model, verbose=True) test_security(model=model, verbose=True) evalidate-2.1.7/evalidate/cli/genfakeproducts.py0000644000000000000000000000667313615410400016656 0ustar00#!/usr/bin/env python3 """ gen_products.py Generate fake e-commerce products (USD, no images) using Faker. Default: 10,000 products → products.json Example: python3 gen_products.py --out /tmp/products.json --count 5000 """ import argparse, json, random, uuid from datetime import datetime, UTC try: from faker import Faker except ImportError: print("Faker library is required. You can reinstall evalidate with the 'generate' extra: pipx install 'evalidate[generate]'") exit(1) fake = Faker() BRANDS = { "phones": ["Samsung", "Apple", "Xiaomi", "OnePlus", "Google"], "laptops": ["Dell", "HP", "Lenovo", "Asus", "Acer", "Apple"], "audio": ["Sony", "JBL", "Bose", "Sennheiser", "Marshall"], "tv": ["LG", "Samsung", "Philips", "TCL", "Panasonic"], "camera": ["Canon", "Nikon", "Sony", "Fujifilm", "GoPro"], "appliances": ["Bosch", "Philips", "Panasonic", "Midea", "Tefal"] } SUFFIXES = ["", " Pro", " Max", " Plus", " Ultra", " SE"] def make_title(category, brand): letters = ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZ', k=2)) numbers = str(random.randint(100, 9999)) suffix = random.choice(SUFFIXES) return f"{brand} {letters}-{numbers}{suffix}" def make_price(category): base = { "phones": 300, "laptops": 800, "audio": 120, "tv": 400, "camera": 600, "appliances": 200 }.get(category, 100) p = base * random.uniform(0.7, 1.8) return round(p - 0.01, 2) def make_description(category, brand): return fake.sentence(nb_words=random.randint(12, 24)) + f" {brand} {category} designed for modern users." def make_attributes(category): attrs = {} if category in ("phones", "laptops"): attrs["storage"] = random.choice(["64GB","128GB","256GB","512GB","1TB"]) if category in ("phones","audio","camera"): attrs["color"] = random.choice(["black","white","blue","red","silver"]) if category in ("tv","appliances"): attrs["power_w"] = random.choice([800,1200,1500,2000]) return attrs def gen_product(): category = random.choice(list(BRANDS.keys())) brand = random.choice(BRANDS[category]) uid = str(uuid.uuid4()) return { "id": uid, "sku": f"SKU-{uid[:8].upper()}", "title": make_title(category, brand), "brand": brand, "category": category, "description": make_description(category, brand), "price": make_price(category), "currency": "USD", "stock": random.randint(0, 500), "rating": round(random.uniform(3.0, 5.0), 2), "reviews_count": random.randint(0, 1000), "attributes": make_attributes(category), "tags": random.sample(["new","bestseller","discount","limited","eco"], k=random.randint(0,3)), } def main(): ap = argparse.ArgumentParser(description="Generate fake e-commerce products in JSON format.") ap.add_argument("-n", "--num", type=int, default=10000, help="Number of products to generate (default: 10000).") ap.add_argument("-o", "--out", type=str, default="products.json", help="Output JSON file path (default: products.json).") args = ap.parse_args() with open(args.out, "w", encoding="utf-8") as f: f.write("[\n") for i in range(args.num): json.dump(gen_product(), f, ensure_ascii=False) if i != args.num - 1: f.write(",\n") f.write("\n]\n") print(f"Wrote {args.num} products to {args.out}") if __name__ == "__main__": main() evalidate-2.1.7/evalidate/cli/jg.py0000755000000000000000000001003213615410400014055 0ustar00#!/usr/bin/env python3 from evalidate import base_eval_model, ValidationException, CompilationException, ExecutionException import json import sys import argparse import time import keyword def short_repr(data, limit=100): try: text = json.dumps(data, ensure_ascii=False) except Exception: text = repr(data) if len(text) > limit: text = text[:limit] + "..." return text def get_args(): parser = argparse.ArgumentParser('Grep JSON files by python expression') parser.add_argument('-b', '--benchmark', default=False, action='store_true', help='Do not print, just search and measure search time') parser.add_argument('-v', '--verbose', default=False, action='store_true', help='Be verbose') parser.add_argument('-l', '--jsonl', default=False, action='store_true', help='input is JSONL (not JSON list)') parser.add_argument('-k', '--keypath', type=str, help='key path to list separated by ::, e.g.: -k shop::products::onstock') parser.add_argument('-f', '--format', type=str, help='format (if not JSON), e.g. -f \'{sku} {price} ({stock}) {title!r}\'') parser.add_argument('-F', '--fix', action='store_true', help='check each dict key and rename if it\'s a reserved word (e.g. "from" -> "_from")') parser.add_argument('expr', nargs='?', help='Python filter expression') parser.add_argument('path', default=None, help='Path to JSON list (or stdin)', nargs='?') return parser.parse_args() def vprint(s: str): if args.verbose: print(s) def main(): global args args = get_args() if args.benchmark: args.verbose = True out = list() if args.expr is None: args.expr = 'True' vprint(f'# loading data from {args.path or "stdin"}') if args.path: f = open(args.path, "r", encoding="utf-8") else: f = sys.stdin with f: if args.jsonl: data = [json.loads(line) for line in f if line.strip()] else: try: data = json.load(f) except json.JSONDecodeError as e: if e.msg == 'Extra data': print(f"Error decoding JSON: {e}", file=sys.stderr) print("Hint: use --jsonl / -l for JSONL files (one JSON object per line)") sys.exit(1) print(f"Error decoding JSON: {e}", file=sys.stderr) print(e.msg) print(type(e)) print(dir(e)) sys.exit(1) # follow keypath if needed try: if args.keypath: for k in args.keypath.split('::'): data = data[k] except KeyError as e: print(f"No such key: {e}", file=sys.stderr) sys.exit(1) if args.fix: print("# fixing reserved words in dict keys") for d in data: if isinstance(d, dict): for k in list(d.keys()): if keyword.iskeyword(k): d[f'_{k}'] = d.pop(k) vprint(f'# loaded {len(data)} records from {args.path}') model = base_eval_model.clone() try: expr = model.expr(args.expr) except (ValidationException, CompilationException) as e: print(e) sys.exit(1) c=0 start = time.time() if not isinstance(data, list): print("data is not a list", file=sys.stderr) print(short_repr(data)) sys.exit(1) for p in data: try: # we use native eval with validated code to get better speed r = eval(expr.code, dict(), p) if r: out.append(p) c+=1 except (KeyError, NameError) as e: pass # print("Runtime exception:", e) elapsed = round(time.time() - start, 2) vprint(f"# {c}/{len(data)} elements found in {elapsed} seconds".format(c)) if not args.benchmark: if args.format: for el in out: print(args.format.format(**el)) else: print(json.dumps(out, indent=2)) if __name__ == '__main__': main()evalidate-2.1.7/examples/basic.py0000755000000000000000000000033113615410400013650 0ustar00#!/usr/bin/env python3 from evalidate import Expr, EvalException src = 'a + 40 > b' # src="__import__('os').system('clear')" try: print(Expr(src).eval({'a':10, 'b':42})) except EvalException as e: print(e) evalidate-2.1.7/tests/__init__.py0000644000000000000000000000000013615410400013640 0ustar00evalidate-2.1.7/tests/test_exc.py0000644000000000000000000000222513615410400013732 0ustar00from evalidate import Expr, ValidationException, ExecutionException, CompilationException, base_eval_model import pytest class TestExceptions(): def test_exec(self): with pytest.raises(ExecutionException) as e: r = Expr('1/0').eval() with pytest.raises(ExecutionException) as e: r = Expr('1+x').eval() def test_badcode(self): with pytest.raises(CompilationException) as e: r = Expr('') with pytest.raises(CompilationException) as e: r = Expr(';') with pytest.raises(CompilationException) as e: r = Expr(""" 1+1 2+2""") def test_call(self): ctx = {'a':36.6} with pytest.raises(ValidationException) as e: r = Expr('int(a)').eval(ctx) def test_return(self): with pytest.raises(CompilationException) as e: Expr('return 42').eval() def test_startswith(self): with pytest.raises(ValidationException) as e: src = '"abcdef".startswith("abc")' m = base_eval_model.clone() m.nodes.append('Call') r = Expr(src).eval() evalidate-2.1.7/tests/test_expr.py0000644000000000000000000000642713615410400014141 0ustar00from evalidate import Expr, EvalException, ValidationException, base_eval_model, EvalModel import pytest class TestExpr(): def test_basic(self): r = Expr('1+2').eval() assert r == 3 def test_mult(self): with pytest.raises(ValidationException): r = Expr('42 * 42').eval() mult_model = base_eval_model.clone() mult_model.nodes.append('Mult') r = Expr('42 * 42', mult_model).eval() assert r == 1764 def test_blank(self): with pytest.raises(ValidationException): model = EvalModel(nodes=[]) r = Expr('1 + 2', model=model).eval() model = EvalModel(nodes=['Expression', 'BinOp', 'Constant', 'Add']) r = Expr('1 + 2', model=model).eval() assert r == 3 def test_cleanup(self): ctx=dict() Expr('None').eval(ctx) assert '__builtins__' not in ctx def test_context(self): e = Expr('a + b') assert e.eval({'a': 1, 'b': 2}) == 3 assert e.eval({'a': 0, 'b': 2}) == 2 def test_functions(self): with pytest.raises(ValidationException): r = Expr('int(x)').eval({'x': 1.3}) model = base_eval_model.clone() model.nodes.append('Call') model.allowed_functions.append('int') r = Expr('int(x)', model=model).eval({ "x": 1.3 }) assert r == 1 def test_attributes(self): class Person: pass class Movie: pass person = Person() person.name = 'Audrey Hepburn' person.birth = 1929 movie1 = Movie() movie1.title = "Roman Holiday" movie1.year = 1953 movie2 = Movie() movie2.title = "Breakfast at Tiffany's" movie2.year = 1961 movies = [movie1, movie2] with pytest.raises(ValidationException): r = Expr('movie.year - person.birth').eval({"person": person, "movie": movie1}) m = base_eval_model.clone() m.nodes.append('Attribute') m.attributes.extend(['year', 'birth']) e = Expr('movie.year - person.birth', model=m) age = e.eval({"person":person, "movie": movie1}) assert age == 24 age = e.eval({"person": person, "movie": movie2}) assert age == 32 ages = [ e.eval({"person": person, "movie": movie}) for movie in movies ] assert ages == [ 24, 32 ] def test_dicts(self): person = { 'name': 'Audrey Hepburn', 'birth': 1929 } movies = [ { 'title': "Roman Holiday", 'year': 1953 }, { 'title': "Breakfast at Tiffany's", 'year': 1961 } ] e = Expr('movie["year"] - person["birth"]') ages = [ e.eval({"movie": movie, "person": person}) for movie in movies ] assert ages == [24,32] def test_my_funcs(self): def double(x): return x * 2 ctx = {'a': 123.45} m = base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') m.imported_functions['double'] = double e = Expr('int(a) + double(a)', model=m) r = e.eval(ctx) assert r == 369.9evalidate-2.1.7/tests/test_feature.py0000644000000000000000000000303613615410400014607 0ustar00import evalidate import pytest class TestSafeEval(): def test_sum(self): r = evalidate.Expr('1+2').eval() assert(r==3) def test_context(self): ctx = {'a':100, 'b': 200 } r = evalidate.Expr('a+b').eval(ctx) assert(r==300) def test_call(self): ctx = {'a':36.6} m = evalidate.base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') r = evalidate.Expr('int(a)', model=m).eval(ctx) assert(r==36) def test_normal(self): codelist = [ ('1+1',2), ('a+b',3), ('int(pi)', 3) ] ctx={'a':1, 'b':2, 'pi': 3.14} m = evalidate.base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') for src, r in codelist: result = evalidate.Expr(src, model=m).eval(ctx) assert(result==r) def test_stack(self): src='int(1)' m = evalidate.base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') for i in range(199): src = f'int( {src} )' r = evalidate.Expr(src, model=m).eval() assert( r==1 ) def test_startswith(self): m = evalidate.base_eval_model.clone() m.nodes.extend(['Call', 'Attribute']) m.allowed_functions.append('int') m.attributes.append('startswith') src = '"abcdef".startswith("abc")' r = evalidate.Expr(src, model=m).eval() assert r evalidate-2.1.7/tests/test_jailbreak.py0000644000000000000000000000475713615410400015113 0ustar00from evalidate import ExecutionException, ValidationException, Expr, base_eval_model import pytest class TestJailbreak(): def test_ossystem_nocall(self): # must fail because calls are not allowed at all with pytest.raises(ValidationException): Expr('os.system("clear")') def test_ossystem_call_int(self): # must fail because this function not allowed with pytest.raises(ValidationException): m = base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') Expr('os.system("clear")', model=m) def test_ossystem_import(self): # must fail anyway with pytest.raises(ValidationException): m = base_eval_model.clone() m.nodes.append('Call') m.allowed_functions.append('int') Expr("__import__('os').system('clear')", model=m) def test_builtins(self): # indirect call src="""__builtins__['eval']("print(1)")""" with pytest.raises(ValidationException): m = base_eval_model.clone() m.nodes.append('Call') result = Expr(src, model=m) def test_bomb(self): bomb_list = [""" (lambda fc=( lambda n: [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n ][0] ): fc("function")( fc("code")( 0,0,0,0,0,b"BOOM",(),(),(),"","",0,b"" ),{} )() )() """, """ (lambda fc=( lambda n: [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n ][0] ): fc("function")( fc("code")( 0,0,0,0,0,0,b"BOOM",(),(),(),"","",0,b"" ),{} )() )() """, """ (lambda fc=( lambda n: [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n ][0] ): fc("function")( fc("code")( 0,0,0,0,0,0,b"BOOM",(),(),(),"","","",0,b"",b"",b"",b"",(),() ),{} )() )() """ ] m = base_eval_model.clone() m.nodes.append('Call') for bomb in bomb_list: with pytest.raises(ValidationException): Expr(expr=bomb, model=m) def test_mul_overflow(self): src = '"a"*1000000*1000000*1000000*1000000' with pytest.raises(ExecutionException): m = base_eval_model.clone() m.nodes.append('Mult') Expr(src, model=m).eval() evalidate-2.1.7/tests/test_locals_globals.py0000644000000000000000000000350213615410400016132 0ustar00# from evalidate import ExecutionException, ValidationException, Expr, base_eval_model from evalidate import Expr, base_eval_model, ValidationException, ExecutionException import pytest from collections import UserDict class LazyDict(UserDict): def __missing__(self, key): return 42 class TestLocalsGlobals(): def test_locals(self): expr = Expr('a + b') ctx = dict(a=1, b=2) result = expr.eval(None, ctx) assert result == 3 def test_globals(self): expr = Expr('a + b') ctx = dict(a=1, b=2) result = expr.eval(ctx) assert result == 3 def test_list_comprehension(self): # prepare model my_model = base_eval_model.clone() my_model.nodes.extend( [ "Call", "Attributes", "ListComp", "DictComp", "comprehension", "Store", "ForOfStatement", "Subscript", "GeneratorExp", "For", ] ) my_model.allowed_functions.append("sum") my_shelve = { "height": 200, "boxes": { "box1": {"volume": 110}, "box2": {"volume": 90}, }, "column_width": [20, 480, 40], } exp_string = "sum([my_shelve['boxes'][box]['volume'] for box in my_shelve['boxes'] ])" exp = Expr(exp_string, my_model) res = exp.eval({"my_shelve": my_shelve}) assert res == 200 def test_userdict(self): ctx = LazyDict(a=100) expr = Expr("a+b") # must raise exception with pytest.raises(ExecutionException): res = expr.eval(ctx_globals=ctx) res = expr.eval(ctx_locals=ctx) assert res == 142 evalidate-2.1.7/tests/test_no_mutation.py0000644000000000000000000000132513615410400015507 0ustar00# from evalidate import ExecutionException, ValidationException, Expr, base_eval_model from evalidate import Expr, base_eval_model, ValidationException, ExecutionException from copy import deepcopy from collections import UserDict class TestNoMutation(): def test_no_mutation_globals(self): expr = Expr('a + b') ctx = dict(a=1, b=2) ctx_orig = deepcopy(ctx) result = expr.eval(ctx_globals=ctx) assert result == 3 assert ctx == ctx_orig def test_no_mutation_locals(self): expr = Expr('a + b') ctx = dict(a=1, b=2) ctx_orig = deepcopy(ctx) result = expr.eval(ctx_locals=ctx) assert result == 3 assert ctx == ctx_orig evalidate-2.1.7/.gitignore0000644000000000000000000000012413615410400012364 0ustar00*~ __pycache__ evalidate.egg-info/ *pyc build/ dist/ .local/ products.json uv.lock evalidate-2.1.7/LICENSE0000644000000000000000000000206213615410400011404 0ustar00MIT License Copyright (c) 2022 Yaroslav Polyakov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. evalidate-2.1.7/README.md0000644000000000000000000003216713615410400011667 0ustar00# Evalidate Evalidate is a simple Python library for safely evaluating untrusted, user-supplied Python expressions. It validates expressions before execution and rejects potentially malicious code. Evalidate also comes with a convenient, high-performance CLI tool, `jg`, for filtering JSON lists using Python expressions: ```shell jg 'attributes["storage"] == "512GB" and price<300 and rating>4' products.json ``` ## Purpose Evalidate was originally developed to filter complex data structures. For example, to find inexpensive smartphones that are available for sale: ```python category=="smartphones" and price<300 and stock>0 ``` However, it can also evaluate other types of expressions, including arithmetic expressions: ```python a+b-100 ``` Evalidate is the fastest secure Python `eval()` alternative I know of. ## Installation ```shell pip3 install evalidate ``` ## TL;DR: Just give me safe `eval()`! ```python from evalidate import base_eval_model, EvalException src = 'a + 10 > b' # src = "__import__('os').system('clear')" model = base_eval_model.clone() expr = model.expr(src) try: print(expr.eval({'a':10, 'b':142})) except EvalException as e: print(e) ``` Output: ``` True ``` If you try to execute the potentially dangerous expression (uncomment the second `src` line), the output will be: ``` ERR: Operation type Call is not allowed ``` Here's a more practical example: finding inexpensive smartphones that meet specific requirements. ```python from evalidate import base_eval_model, EvalException mobiles = [ {'brand': 'Apple', 'model': 'iPhone 15', 'price': 799, 'RAM': 6, 'storage': 128}, {'brand': 'Apple', 'model': 'iPhone 14', 'price': 799, 'RAM': 6, 'storage': 128}, {'brand': 'Samsung', 'model': 'Galaxy S23 Ultra', 'price': 1199, 'RAM': 8, 'storage': 256}, {'brand': 'Samsung', 'model': 'Galaxy A54', 'price': 449, 'RAM': 8, 'storage': 128}, {'brand': 'Samsung', 'model': 'Galaxy Z Flip 5', 'price': 999, 'RAM': 8, 'storage': 256}, {'brand': 'Google', 'model': 'Pixel 8', 'price': 699, 'RAM': 8, 'storage': 128}, {'brand': 'Google', 'model': 'Pixel 7a', 'price': 499, 'RAM': 8, 'storage': 128}, {'brand': 'OnePlus', 'model': 'OnePlus 11', 'price': 699, 'RAM': 16, 'storage': 256}, # intentionally incomplete: {'brand': 'Xiaomi', 'model': 'Xiaomi 13 Pro', 'price': 999, 'RAM': 12, }, {'brand': 'Xiaomi', 'model': 'Redmi Note 12 Pro', 'price': 299, 'RAM': 8, 'storage': 256}, ] src = 'storage >= 256 and RAM >= 8 and price <= 1000' model = base_eval_model.clone() try: expr = model.expr(src) except EvalException as e: print(f"Error in expression ({src}): {e}") exit(1) for mobile in mobiles: try: if expr.eval(mobile): print(f"{mobile['brand']} {mobile['model']} {mobile['storage']}/{mobile['RAM']} ({mobile['price']})") except EvalException as e: print(f"ERROR: evaluating expression for {mobile['brand']} {mobile['model']}: {e}") ``` ## Security Python's built-in functions, such as `compile()` and `eval()`, are powerful enough to execute arbitrary user-supplied code. Using them with untrusted input can therefore be dangerous. For example, an expression might attempt to execute `os.system("rm -rf /")`. Evalidate uses a whitelist-based approach: an expression is accepted only if it consists of operations explicitly permitted by the configured security model. What counts as safe depends on the model's configuration and the author's security assumptions. Your requirements may differ, so you can define your own list of allowed operations. Although the built-in models are designed to be secure, Evalidate allows you to extend them. Do so carefully: even an attribute that appears harmless, such as `.format`, can introduce vulnerabilities. ## Exceptions Evalidate defines three exception classes: `CompilationException`, `ValidationException`, and `ExecutionException`. All three inherit from the base exception class `EvalException`. ## Models: Configuring Validation Evalidate is highly configurable. Whether an expression passes validation or raises an exception depends on the security model used. `EvalModel` defines the security policy for expression evaluation. It specifies the allowed AST nodes, function calls, and attributes, as well as a dictionary of imported functions. Evalidate provides a built-in model, `base_eval_model`, which permits a basic set of operations considered safe by the author. You can create an empty model and extend it as needed: ```python my_model = evalidate.EvalModel() ``` By default, an empty model allows no operations. Even `1+2` is rejected. Alternatively, you can clone `base_eval_model` and extend it: ```python from evalidate import base_eval_model my_model = base_eval_model.clone() my_model.nodes.append('Mult') expr = my_model.expr('2*2') expr.eval() ``` To enable calls to `int()`, allow the `Call` node and add `int` to the list of permitted functions: ```python my_model.nodes.append('Call') my_model.allowed_functions.append('int') my_model.expr('int(36.6)').eval() ``` To enable attribute access and method calls, configure the model as follows: ```python m = base_eval_model.clone() m.nodes.extend(['Call', 'Attribute']) m.attributes.append('startswith') src = '"abcdef".startswith("abc")' r = m.expr(src).eval() print(r) ``` Even with this configuration, an expression such as `__builtins__["eval"](1)` is rejected. ### Exporting Functions to Expressions You can explicitly expose your own functions to evaluated expressions: ```python from evalidate import base_eval_model def one(): return 1 m = base_eval_model.clone() m.nodes.append('Call') m.imported_functions["one"] = one m.expr('one()').eval() ``` ### Testing a Model's Security Evalidate includes a built-in collection of potential attacks and a `test_security()` method that tests a model against them. Passing `test_security()` is necessary, but not sufficient, to establish that a model is secure. Even if `test_security()` returns `True`, the model may still be vulnerable to attacks that the test suite does not cover. ```python from evalidate import base_eval_model m = base_eval_model.clone() m.nodes.append('Pow') assert(m.test_security()) src = '10**10**10' m.expr(src).eval() ``` ## CLI Tools ### `genfakeproducts` `genfakeproducts` generates a JSON array containing a specified number of fake product records for testing. For example, to generate one million records (this takes about one minute on my computer): ```shell genfakeproducts -n 1000000 -o products.json ``` By default, it generates 10,000 records and writes them to `products.json`. This takes approximately one second. Example record: ```json { "id": "d5af9f93-c164-4872-a535-4fb56cf62e6e", "sku": "SKU-D5AF9F93", "title": "Google ET-3843 Pro", "brand": "Google", "category": "phones", "description": "Foot trial city avoid he wish real college investment tend include draw window either over somebody history detail risk support glass. Google phones designed for modern users.", "price": 340.94, "currency": "USD", "stock": 24, "rating": 4.23, "reviews_count": 222, "attributes": { "storage": "512GB", "color": "red" }, "tags": [ "limited", "new" ] } ``` To use `genfakeproducts`, install Evalidate with the `generate` extra: ```shell pip3 install evalidate[generate] ``` ### `jg` (JSON grep) `jg` is a high-performance CLI tool for filtering JSON data using Python expressions. It accepts an expression and a filename, or reads input from standard input. ``` jg 'category=="phones"' products.json ``` `jg` implements only a small subset of `jq`'s functionality, but it is twice as fast and much simpler to use. **Options** - `-b` — Benchmark mode. Suppresses JSON output and measures filtering time. - `-v` — Verbose mode. - `-l` — Read input as JSON Lines (JSONL), with one JSON object per line, instead of a JSON array. - `-k` — Specify the key path to the list, such as `"shop::products::onstock"`, when the input is a nested dictionary. - `-f` — Specify a custom output format for non-JSON output, e.g. `'{sku} {price} ({stock}) {title!r}'`. ## Advanced Topics ### Improve Performance by Using Native `eval()` on Validated Code Evalidate is fast, but its evaluation machinery still introduces some overhead. If you need maximum performance, you can use Python's native [`eval()`](https://docs.python.org/3/library/functions.html#eval) directly on the validated code: ```python from evalidate import base_eval_model m = base_eval_model.clone() d = dict(a=1, b=2) expr = m.expr('a+b') r = eval(expr.code, None, d) # <-- native python eval, will run at eval() speed print(r) ``` This approach provides the same expression-validation guarantees as `Model.Expr.eval()`, because `expr.code` has already passed validation. The differences are minor but important: executing `expr.code` directly can raise arbitrary exceptions, whereas `expr.eval()` raises only `ExecutionException`. Also, if you want to expose functions to the expression, you must do so manually. ### Custom Mapping Classes You can use custom mapping classes instead of `dict`. As with native Python `eval()`, a custom mapping class can be used only as the local evaluation context. Example: ```python from collections import UserDict from evalidate import base_eval_model class LazyDict(UserDict): def __missing__(self, key): return 42 ctx = LazyDict(a=100) model = base_eval_model.clone() expr = model.expr("a+b") res = expr.eval(ctx_locals=ctx) print(res) # 142 ``` ### Limitations Evalidate uses [`ast.parse()`](https://docs.python.org/3/library/ast.html#ast.parse) to generate an [abstract syntax tree (AST)](https://docs.python.org/3/library/ast.html#node-classes), which it then validates. > **Warning** > > A sufficiently large or deeply nested input string can crash the Python interpreter because of stack-depth limitations in Python's AST compiler. In my tests, expressions containing 200 nested `int()` calls work: ```python int(int(.... int(1)...)) ``` but expressions with 201 nested calls do not. The source code is over 1,000 characters long. Even if Evalidate receives such an expression, it will raise `CompilationException`. ## Similar Projects and Benchmarks ### [asteval](https://newville.github.io/asteval/) `asteval` supports more complex code, including function definitions and access to Python's mathematical libraries. However, it has two drawbacks: - It is significantly slower than Evalidate, which can evaluate validated code at native `eval()` speed. - User-supplied expressions can run for a long time and consume excessive resources. ### [simpleeval](https://github.com/danthedeckie/simpleeval) `simpleeval` is a similar project that also uses an AST-based approach and is optimized for repeatedly evaluating pre-parsed expressions. However, it evaluates expressions using a higher-level representation based on [`ast.Expr`](https://docs.python.org/3/library/ast.html#ast.Expr), which makes evaluation several times slower. Evalidate instead compiles validated expressions into Python's native `code` objects, allowing execution at native `eval()` speed. Evalidate is particularly well suited to evaluating the same expression against many different data objects. ## Benchmarking The benchmark script is `benchmark/benchmark.py` in this repository. The benchmark creates a list of one million products. In practice, it takes a sample of 100 products and repeats it 10,000 times. It then filters the list using an untrusted user-supplied expression to find matching products. The expression in this test is `price < 20`. ``` Products: 1000000 items evalidate_raw_eval(): 0.266s evalidate_eval(): 0.326s test_simpleeval(): 1.824s test_asteval(): 26.106s ``` As the results show, Evalidate is several times faster than `simpleeval`, and both are significantly faster than `asteval`. The benchmark may not be optimally implemented; I am not an expert in `simpleeval` or `asteval`. If you can suggest a faster filtering implementation, I will consider including it. Any proposed benchmark code must treat the expression as unknown in advance and potentially untrusted. ## Further Reading: The Risks of `eval()` - [Eval really is dangerous](https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html) - [Breaking Python 3 eval protections](https://netsec.expert/posts/breaking-python3-eval-protections/) - [Python eval() function](https://realpython.com/python-eval-function/) **Note:** The Real Python article demonstrates a concise source-validation technique based on `code.co_names`. However, this technique is vulnerable: it accepts the "bomb" from Ned Batchelder's article, which has an empty `co_names` tuple and can crash the interpreter. Evalidate can reject this code and similar bombs, unless you deliberately configure the model to allow the specific operations required by an attack. Evalidate makes it harder to shoot yourself in the foot, but it is still possible if you try hard enough. ## More Information For more information, see the module's source code. It is short, straightforward, and easy to modify. ## Contact Email: yaroslaff at gmail.com evalidate-2.1.7/pyproject.toml0000644000000000000000000000262413615410400013317 0ustar00[build-system] # requires = ["setuptools >= 40.6.0", "wheel"] requires = ["hatchling>=1.27.0", "wheel"] # build-backend = "setuptools.build_meta" build-backend = "hatchling.build" [project] name="evalidate" dynamic = [ "version" ] keywords = ["python", "security", "json", "sandbox", "filter", "evaluation", "expression", "secure", "jq", "jg", "validate", "safe", "grep", "eval", "evaluate", "jg"] dependencies = [] authors = [ { name="Yaroslav Polyakov", email="yaroslaff@gmail.com" }, ] description = "Validation and secure evaluation of untrusted python expressions" readme = "README.md" requires-python = ">=3.8" classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] [project.optional-dependencies] generate = ["faker"] [project.urls] Homepage = "https://github.com/yaroslaff/evalidate" Issues = "https://github.com/yaroslaff/evalidate/issues" [project.scripts] genfakeproducts = "evalidate.cli.genfakeproducts:main" jg = "evalidate.cli.jg:main" [tool.hatch.version] path = 'evalidate/__init__.py' evalidate-2.1.7/PKG-INFO0000644000000000000000000003425513615410400011505 0ustar00Metadata-Version: 2.4 Name: evalidate Version: 2.1.7 Summary: Validation and secure evaluation of untrusted python expressions Project-URL: Homepage, https://github.com/yaroslaff/evalidate Project-URL: Issues, https://github.com/yaroslaff/evalidate/issues Author-email: Yaroslav Polyakov License-File: LICENSE Keywords: eval,evaluate,evaluation,expression,filter,grep,jg,jq,json,python,safe,sandbox,secure,security,validate Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Requires-Python: >=3.8 Provides-Extra: generate Requires-Dist: faker; extra == 'generate' Description-Content-Type: text/markdown # Evalidate Evalidate is a simple Python library for safely evaluating untrusted, user-supplied Python expressions. It validates expressions before execution and rejects potentially malicious code. Evalidate also comes with a convenient, high-performance CLI tool, `jg`, for filtering JSON lists using Python expressions: ```shell jg 'attributes["storage"] == "512GB" and price<300 and rating>4' products.json ``` ## Purpose Evalidate was originally developed to filter complex data structures. For example, to find inexpensive smartphones that are available for sale: ```python category=="smartphones" and price<300 and stock>0 ``` However, it can also evaluate other types of expressions, including arithmetic expressions: ```python a+b-100 ``` Evalidate is the fastest secure Python `eval()` alternative I know of. ## Installation ```shell pip3 install evalidate ``` ## TL;DR: Just give me safe `eval()`! ```python from evalidate import base_eval_model, EvalException src = 'a + 10 > b' # src = "__import__('os').system('clear')" model = base_eval_model.clone() expr = model.expr(src) try: print(expr.eval({'a':10, 'b':142})) except EvalException as e: print(e) ``` Output: ``` True ``` If you try to execute the potentially dangerous expression (uncomment the second `src` line), the output will be: ``` ERR: Operation type Call is not allowed ``` Here's a more practical example: finding inexpensive smartphones that meet specific requirements. ```python from evalidate import base_eval_model, EvalException mobiles = [ {'brand': 'Apple', 'model': 'iPhone 15', 'price': 799, 'RAM': 6, 'storage': 128}, {'brand': 'Apple', 'model': 'iPhone 14', 'price': 799, 'RAM': 6, 'storage': 128}, {'brand': 'Samsung', 'model': 'Galaxy S23 Ultra', 'price': 1199, 'RAM': 8, 'storage': 256}, {'brand': 'Samsung', 'model': 'Galaxy A54', 'price': 449, 'RAM': 8, 'storage': 128}, {'brand': 'Samsung', 'model': 'Galaxy Z Flip 5', 'price': 999, 'RAM': 8, 'storage': 256}, {'brand': 'Google', 'model': 'Pixel 8', 'price': 699, 'RAM': 8, 'storage': 128}, {'brand': 'Google', 'model': 'Pixel 7a', 'price': 499, 'RAM': 8, 'storage': 128}, {'brand': 'OnePlus', 'model': 'OnePlus 11', 'price': 699, 'RAM': 16, 'storage': 256}, # intentionally incomplete: {'brand': 'Xiaomi', 'model': 'Xiaomi 13 Pro', 'price': 999, 'RAM': 12, }, {'brand': 'Xiaomi', 'model': 'Redmi Note 12 Pro', 'price': 299, 'RAM': 8, 'storage': 256}, ] src = 'storage >= 256 and RAM >= 8 and price <= 1000' model = base_eval_model.clone() try: expr = model.expr(src) except EvalException as e: print(f"Error in expression ({src}): {e}") exit(1) for mobile in mobiles: try: if expr.eval(mobile): print(f"{mobile['brand']} {mobile['model']} {mobile['storage']}/{mobile['RAM']} ({mobile['price']})") except EvalException as e: print(f"ERROR: evaluating expression for {mobile['brand']} {mobile['model']}: {e}") ``` ## Security Python's built-in functions, such as `compile()` and `eval()`, are powerful enough to execute arbitrary user-supplied code. Using them with untrusted input can therefore be dangerous. For example, an expression might attempt to execute `os.system("rm -rf /")`. Evalidate uses a whitelist-based approach: an expression is accepted only if it consists of operations explicitly permitted by the configured security model. What counts as safe depends on the model's configuration and the author's security assumptions. Your requirements may differ, so you can define your own list of allowed operations. Although the built-in models are designed to be secure, Evalidate allows you to extend them. Do so carefully: even an attribute that appears harmless, such as `.format`, can introduce vulnerabilities. ## Exceptions Evalidate defines three exception classes: `CompilationException`, `ValidationException`, and `ExecutionException`. All three inherit from the base exception class `EvalException`. ## Models: Configuring Validation Evalidate is highly configurable. Whether an expression passes validation or raises an exception depends on the security model used. `EvalModel` defines the security policy for expression evaluation. It specifies the allowed AST nodes, function calls, and attributes, as well as a dictionary of imported functions. Evalidate provides a built-in model, `base_eval_model`, which permits a basic set of operations considered safe by the author. You can create an empty model and extend it as needed: ```python my_model = evalidate.EvalModel() ``` By default, an empty model allows no operations. Even `1+2` is rejected. Alternatively, you can clone `base_eval_model` and extend it: ```python from evalidate import base_eval_model my_model = base_eval_model.clone() my_model.nodes.append('Mult') expr = my_model.expr('2*2') expr.eval() ``` To enable calls to `int()`, allow the `Call` node and add `int` to the list of permitted functions: ```python my_model.nodes.append('Call') my_model.allowed_functions.append('int') my_model.expr('int(36.6)').eval() ``` To enable attribute access and method calls, configure the model as follows: ```python m = base_eval_model.clone() m.nodes.extend(['Call', 'Attribute']) m.attributes.append('startswith') src = '"abcdef".startswith("abc")' r = m.expr(src).eval() print(r) ``` Even with this configuration, an expression such as `__builtins__["eval"](1)` is rejected. ### Exporting Functions to Expressions You can explicitly expose your own functions to evaluated expressions: ```python from evalidate import base_eval_model def one(): return 1 m = base_eval_model.clone() m.nodes.append('Call') m.imported_functions["one"] = one m.expr('one()').eval() ``` ### Testing a Model's Security Evalidate includes a built-in collection of potential attacks and a `test_security()` method that tests a model against them. Passing `test_security()` is necessary, but not sufficient, to establish that a model is secure. Even if `test_security()` returns `True`, the model may still be vulnerable to attacks that the test suite does not cover. ```python from evalidate import base_eval_model m = base_eval_model.clone() m.nodes.append('Pow') assert(m.test_security()) src = '10**10**10' m.expr(src).eval() ``` ## CLI Tools ### `genfakeproducts` `genfakeproducts` generates a JSON array containing a specified number of fake product records for testing. For example, to generate one million records (this takes about one minute on my computer): ```shell genfakeproducts -n 1000000 -o products.json ``` By default, it generates 10,000 records and writes them to `products.json`. This takes approximately one second. Example record: ```json { "id": "d5af9f93-c164-4872-a535-4fb56cf62e6e", "sku": "SKU-D5AF9F93", "title": "Google ET-3843 Pro", "brand": "Google", "category": "phones", "description": "Foot trial city avoid he wish real college investment tend include draw window either over somebody history detail risk support glass. Google phones designed for modern users.", "price": 340.94, "currency": "USD", "stock": 24, "rating": 4.23, "reviews_count": 222, "attributes": { "storage": "512GB", "color": "red" }, "tags": [ "limited", "new" ] } ``` To use `genfakeproducts`, install Evalidate with the `generate` extra: ```shell pip3 install evalidate[generate] ``` ### `jg` (JSON grep) `jg` is a high-performance CLI tool for filtering JSON data using Python expressions. It accepts an expression and a filename, or reads input from standard input. ``` jg 'category=="phones"' products.json ``` `jg` implements only a small subset of `jq`'s functionality, but it is twice as fast and much simpler to use. **Options** - `-b` — Benchmark mode. Suppresses JSON output and measures filtering time. - `-v` — Verbose mode. - `-l` — Read input as JSON Lines (JSONL), with one JSON object per line, instead of a JSON array. - `-k` — Specify the key path to the list, such as `"shop::products::onstock"`, when the input is a nested dictionary. - `-f` — Specify a custom output format for non-JSON output, e.g. `'{sku} {price} ({stock}) {title!r}'`. ## Advanced Topics ### Improve Performance by Using Native `eval()` on Validated Code Evalidate is fast, but its evaluation machinery still introduces some overhead. If you need maximum performance, you can use Python's native [`eval()`](https://docs.python.org/3/library/functions.html#eval) directly on the validated code: ```python from evalidate import base_eval_model m = base_eval_model.clone() d = dict(a=1, b=2) expr = m.expr('a+b') r = eval(expr.code, None, d) # <-- native python eval, will run at eval() speed print(r) ``` This approach provides the same expression-validation guarantees as `Model.Expr.eval()`, because `expr.code` has already passed validation. The differences are minor but important: executing `expr.code` directly can raise arbitrary exceptions, whereas `expr.eval()` raises only `ExecutionException`. Also, if you want to expose functions to the expression, you must do so manually. ### Custom Mapping Classes You can use custom mapping classes instead of `dict`. As with native Python `eval()`, a custom mapping class can be used only as the local evaluation context. Example: ```python from collections import UserDict from evalidate import base_eval_model class LazyDict(UserDict): def __missing__(self, key): return 42 ctx = LazyDict(a=100) model = base_eval_model.clone() expr = model.expr("a+b") res = expr.eval(ctx_locals=ctx) print(res) # 142 ``` ### Limitations Evalidate uses [`ast.parse()`](https://docs.python.org/3/library/ast.html#ast.parse) to generate an [abstract syntax tree (AST)](https://docs.python.org/3/library/ast.html#node-classes), which it then validates. > **Warning** > > A sufficiently large or deeply nested input string can crash the Python interpreter because of stack-depth limitations in Python's AST compiler. In my tests, expressions containing 200 nested `int()` calls work: ```python int(int(.... int(1)...)) ``` but expressions with 201 nested calls do not. The source code is over 1,000 characters long. Even if Evalidate receives such an expression, it will raise `CompilationException`. ## Similar Projects and Benchmarks ### [asteval](https://newville.github.io/asteval/) `asteval` supports more complex code, including function definitions and access to Python's mathematical libraries. However, it has two drawbacks: - It is significantly slower than Evalidate, which can evaluate validated code at native `eval()` speed. - User-supplied expressions can run for a long time and consume excessive resources. ### [simpleeval](https://github.com/danthedeckie/simpleeval) `simpleeval` is a similar project that also uses an AST-based approach and is optimized for repeatedly evaluating pre-parsed expressions. However, it evaluates expressions using a higher-level representation based on [`ast.Expr`](https://docs.python.org/3/library/ast.html#ast.Expr), which makes evaluation several times slower. Evalidate instead compiles validated expressions into Python's native `code` objects, allowing execution at native `eval()` speed. Evalidate is particularly well suited to evaluating the same expression against many different data objects. ## Benchmarking The benchmark script is `benchmark/benchmark.py` in this repository. The benchmark creates a list of one million products. In practice, it takes a sample of 100 products and repeats it 10,000 times. It then filters the list using an untrusted user-supplied expression to find matching products. The expression in this test is `price < 20`. ``` Products: 1000000 items evalidate_raw_eval(): 0.266s evalidate_eval(): 0.326s test_simpleeval(): 1.824s test_asteval(): 26.106s ``` As the results show, Evalidate is several times faster than `simpleeval`, and both are significantly faster than `asteval`. The benchmark may not be optimally implemented; I am not an expert in `simpleeval` or `asteval`. If you can suggest a faster filtering implementation, I will consider including it. Any proposed benchmark code must treat the expression as unknown in advance and potentially untrusted. ## Further Reading: The Risks of `eval()` - [Eval really is dangerous](https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html) - [Breaking Python 3 eval protections](https://netsec.expert/posts/breaking-python3-eval-protections/) - [Python eval() function](https://realpython.com/python-eval-function/) **Note:** The Real Python article demonstrates a concise source-validation technique based on `code.co_names`. However, this technique is vulnerable: it accepts the "bomb" from Ned Batchelder's article, which has an empty `co_names` tuple and can crash the interpreter. Evalidate can reject this code and similar bombs, unless you deliberately configure the model to allow the specific operations required by an attack. Evalidate makes it harder to shoot yourself in the foot, but it is still possible if you try hard enough. ## More Information For more information, see the module's source code. It is short, straightforward, and easy to modify. ## Contact Email: yaroslaff at gmail.com