Bienvenue sur IndexError.

Ici vous pouvez poser des questions sur Python et le Framework Django.

Consultez la FAQ pour améliorer vos chances d'avoir des réponses à vos questions.

Que faire pour remplacer eval() dans ce cas ?

+4 votes

Nous avons une application en interne qui sert à traiter arbitrairement des conditions sur un contexte donné (plus ou moins un dictionnaire). L'implémentation actuelle de cette application consiste à exécuter dynamiquement du code (expression lambda) stocké dans une base de données via le built-in eval.

class ConditionHelper(Helper):
    def test(self, contexte, conditions, criteres=None, operateur=all, force=True, **kwargs):
        """
        Teste un ensemble de conditions sur un contexte donné
        :param contexte: Contexte à évaluer
        :param conditions: Liste des conditions à évaluer
        :param criteres: Critères de regroupement complémentaires
        :param operateur: Opérateur entre les résultats : ET (all) ou OR (any)
        :param force: Ignore les erreurs à l'évaluation des conditions mais invalide le test
        :return: Booléen
        """
        resultats = []
        criteres = criteres or []
        for condition in conditions:
            liste_criteres = condition.liste_criteres or [] + criteres or []
            communs = self.get_contextes_communs(contexte, *liste_criteres)
            # Si une stratégie est présente sur la condition, elle sera exécutée sur l'ensemble des contextes
            if condition.strategie:
                results = []
                for ctx in communs:
                    try:
                        result = evaluate(parse_source(condition.source), _globals=dict(decimal=decimal), _locals=dict(
                            C=self._constantes, D=to_object(ctx), F=self._fonctions, R=self._referentiels, T=to_object(communs)))
                        results.append(result)
                        # Arrêt potentiel du parcours si la condition trouvée est valide
                        if result and condition.strategie == Condition.STRATEGIE_ANY:
                            break
                    except Exception as erreur:
                        if not force:
                            raise
                        self.logger.context_error(ctx, messages.CONDITION_INVALIDE, nom=condition, erreur=erreur)
                        return False
                # Si l'opération doit vérifier toutes les conditions
                if condition.strategie == Condition.STRATEGIE_ALL:
                    resultats.append(all(e for e in results))
                # Dans les autres cas, il suffit qu'une seule condition soit valide
                else:
                    resultats.append(any(e for e in results))
            # Sans stratégie globale, l'évaluation s'exécute sur le contexte courant
            else:
                try:
                    result = evaluate(parse_source(condition.source), _globals=dict(decimal=decimal), _locals=dict(
                        C=self._constantes, D=to_object(contexte), F=self._fonctions, R=self._referentiels, T=to_object(communs)))
                    resultats.append(bool(result))
                except Exception as erreur:
                    if not force:
                        raise
                    self.logger.context_error(contexte, messages.CONDITION_INVALIDE, nom=condition, erreur=erreur)
                    return False
            if not operateur(r for r in resultats):
                break
        return operateur(r for r in resultats)

La fonction evaluate est un wrapper "safe" de eval supprimant volontairement certains built-in jugés dangereux ou du moins inadéquats dans notre contexte utilisateur.

On force également le contexte local de la fonction pour s'assurer de n'exploiter que les données que l'on désire (D = Données contextuelles, C = constantes définies par un autre helper, R = Tables référentielles définies par un autre helper, F = Fonctions externes définies par un autre helper)

# Liste des built-ins considérés comme "sûrs"
SAFE_GLOBALS = dict(__builtins__=dict(
    abs=abs,
    all=all,
    any=any,
    ascii=ascii,
    bin=bin,
    bool=bool,
    bytearray=bytearray,
    bytes=bytes,
    # callable=callable,
    chr=chr,
    # classmethod=classmethod,
    # compile=compile,
    complex=complex,
    delattr=delattr,
    dict=dict,
    # dir=dir,
    divmod=divmod,
    enumerate=enumerate,
    # eval=eval,
    # exec=exec,
    filter=filter,
    float=float,
    format=format,
    frozenset=frozenset,
    getattr=getattr,
    # globals=globals,
    hasattr=hasattr,
    hash=hash,
    help=help,
    hex=hex,
    id=id,
    # input=input,
    int=int,
    # isinstance=isinstance,
    # issubclass=issubclass,
    iter=iter,
    len=len,
    list=list,
    # locals=locals,
    map=map,
    max=max,
    # memoryview=memoryview,
    min=min,
    next=next,
    # object=object,
    oct=oct,
    # open=open,
    ord=ord,
    pow=pow,
    # print=print,
    # property=property,
    range=range,
    repr=repr,
    reversed=reversed,
    round=round,
    set=set,
    setattr=setattr,
    slice=slice,
    sorted=sorted,
    # staticmethod=staticmethod,
    str=str,
    sum=sum,
    # super=super,
    tuple=tuple,
    # type=type,
    # vars=vars,
    zip=zip,
    # __import__=__import__,
))


def evaluate(expression, _globals=None, _locals=None, default=False):
    """
    Evalue une expression Python
    :param expression: Expression
    :param _globals: Contexte global
    :param _locals: Contexte local
    :param default: Comportement par défaut ?
    :return: Résultat de l'évaluation
    """
    if not _globals:
        _globals = globals()
    if not _locals:
        _locals = locals()
    if not default:
        _globals.update(SAFE_GLOBALS)
    return eval(expression, _globals, _locals)

Bien sûr, nous savons que eval, même dans ces conditions, est très loin d'être sûr, nous avons fait ce qui était possible pour ne pas faciliter le travail d'un éventuel utilisateur malveillant mais nous aimerions avoir votre avis sur une façon de sécuriser encore plus ce fonctionnement, quitte à le repenser.

On a joué énormément avec et il nous a été possible d'accéder à des fonctions dangereuses en exploitant l'accessibilité et l'introspection de Python, même si c'est clairement pas adressé au développeur quelconque. Typiquement, cette ligne permet de retrouver les built-in d'origine, c'est tordu mais ça marche :

[c for c in ().__class__.__base__.__subclasses__()  if c.__name__ == 'catch_warnings'][0]()._module.__builtins__

Merci pour vos conseils !

demandé 23-Aou-2015 par debnet (1,024 points)

J'ai du tronquer la classe Helper pour publier le message, si vous en avez besoin en entier je peux vous fournir un paste. ;)

Je ne sais pas si c'est voulu ou si c'est un effet de bord, mais quand le parametre _globals n'est pas valorisé, la fonction evaluate utilise une référence (vs une copie) des globals et les modifie en dehors de son scope, ce qui supprime certains built-ins system-wide (ici avec "callable"):

In [5]: callable
Out[5]: <function callable>

In [6]: evaluate('print("a")')
a

In [7]: callable
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-7-fbead07fd8ac> in <module>()
----> 1 callable
NameError: name 'callable' is not defined

Quels sont les types de verifs (peuvent elles être remplacées par des regex ?). Quel type d'utilisateur peut saisir ou modifier du code ? De nouvelles vérifs sont elles souvent ajoutées ? Les vérifs vérifient quoi exactement ? Quand sont-elles appliquées ? Pourraient-elles être appliquées côté client ?

@jc : c'est bien un effet de bord, on ne l'a jamais utilisé sans fournir _globals, je vais corriger ça, merci !

@Sam : Alors dans l'ordre :

  • Pour l'instant la seule vérification est une analyse syntaxicale et la confirmation qu'on n'utilise qu'une seule expression. Il est peut être possible de renforcer avec des regex, mais lesquelles ?
  • Seul un "administrateur" peut ajouter et modifier du code dans l'application.
  • De nouvelles vérifications ne sont pas ajouter, elles sont globales à l'ensemble des conditions créées par les utilisateurs. Par contre de nouvelles conditions peuvent apparaître très régulièrement, des plus simples aux plus complexes.
  • Les vérifications sont appliqués à la sauvegarde de l'entité Condition.
  • Oui, des vérifications peuvent être également appliquées côté client, mais comme c'est du code Python natif ça me semble plus compliqué.

Plusieurs solutions possibles :

  • limiter les profiles admin. Si seuls les admins peuvent injecter du code, alors l'essentiel est de bien recruter ses admins et bien logger qui sauvegarde quel code avant de l'executer afin d'apporter une protection suffisante. Si vous avez un admin malicieux, le problème est plus grave qu'un exec.
  • si les vérifications peuvent être faites uniquement à base de regex (c'est souvent le cas), alors on peut juste stocker le motif de la regex au lieu du code python.
  • si il y a quelques besoins custos qui ne tiennent pas par des regex, fournir du code tout fait et sauvegarder uniquement quelques paramètres.
  • si besoin d'un code très complexes mais uniquement côté client, alors faire le check en JS et sauvegarder le code JS. Le check sera limité à la machine de l'admin qui l'a tapé, et il va pas se hacker lui-même.

Pour résumer le contexte, ce module de gestion des conditions sert dans des applications assez critiques : tarification, gestion des pièces justificatives, dossiers médicaux, etc... donc on limite volontairement tout traitement côté client hormis quelques interactions cloisonnées.

Clairement, seul un gestionnaire habilité pourra saisir de nouvelles conditions/fonctions/constantes/référentiels dans notre système, notre interrogation se situe plutôt sur le fond plus que sur la forme : eval() est vu à raison comme un outil de traitement dynamique de code mais de part sa difficulté de debuggage, ses performances et les failles béantes qu'elle laisse ça nous semblait bon d'avoir un avis consultatif sur la question.

Alors certes, ça simplifie clairement l'écriture de notre module (et donc sa compréhension), on a essayé de limiter au maximum la casse, mais d'un point de vue personnel ça nous gêne d'y avoir recours.

Pour la vérif par regexp, ça me parait compliqué / pas possible.

Tu pourrais vérifier la présence de certains patterns comme la chaine "__builtins__", mais c'est très loin d'être bulletproof :

In [2]: evaluate("getattr([c for c in ().__class__.__base__.__subclasses__()  if c.__name__ == 'catch_warnings'][0]()._module, '__'+'b'+'uiltins__')")
Out[2]: 
{'ArithmeticError': ArithmeticError,
...
'zip': zip}

Un peu plus sournois :

evaluate("getattr([c for c in ().__class__.__base__.__subclasses__()  if c.__name__ == 'catch_warnings'][0]()._module, ''.join( ''.join([ chr(int(c)) for c in '95 95 98 117 105 108 116 105 110 115 95 95'.split(' ')])))")
Out[3]:
{'ArithmeticError': ArithmeticError,
 ...
  'zip': zip}

Non je parle de remplacer ton code par des regex, mais comme je savais pas quel type de code s'était. En même temps, les gars doivent savoir coder, et sont les seuls à accéder au système, donc il n'y a pas de grosse différence avec le fait d'écrire un module sur le serveur directement, c'est juste qu'ici au lieu de faire "import" tu fais un eval. Faut juste blinder la sécu pour être sur que seuls ces gars auront accès à cette fonctionalité.

Pour ceux qui ne savent pas "coder", on a un petit outil leur permettant de faire quelques conditions simples, ils n'ont pas la main sur le code dans ces cas là.

1 Réponse

0 votes

Limite l’exécution de python à un sous ensemble de python est limité en python pur est considéré comme impossible (voir par exemple: https://lwn.net/Articles/574215/)

La plus aboutie tentative a été pysandbox par Victor Stinner qui met un gros avertissement :
WARNING: pysandbox is BROKEN BY DESIGN, please move to a new sandboxing solution (run python in a sandbox, not the opposite!)

La seule solution est d'utiliser une sandbox, les possibilités sont plus nombreuses dans l'environnement unix, mais comme tu désire qu'elle fonctionne également sur windows (précision demandée via irc), une sandbox à la base de PyPy me semble la seule pertinente. (une solution à partir de VM pourrait fonctionner mais me semble trop lourde)

voir http://doc.pypy.org/en/latest/sandbox.html pour un mode d'emploi minimal

La création de la sandbox prend du temps (première ligne de http://doc.pypy.org/en/latest/sandbox.html#howto )

Après avoir fait plusieurs tentatives, je suis arrivé a exécuter un hello world

$ cat temp/test.py 
print("hello world")
$ pypy ./pypy_interact.py --tmp=temp/ ../goal/pypy-c -S /tmp/test.py
hello world
répondu 23-Aou-2015 par Xavier Combelle (164 points)

Un peu overkill dans mon cas, d'autant plus que j'ai besoin de performances : par exemple une tarification exécute plusieurs milliers de conditions.

@debnet : Pypy étant ce qui se fait de mieux en terme de performance en pur python (passé le warmup du JIT), je vois pas en quoi c'est un problème. Concernant le coté overkill de la solution, je te laisse juge, mais si j'ai bien suivi, tu ne souhaite pas que quelqu'un compétent en python qui saisi une formule python ne puisse pas modifier ton application, tout en laissant la possibilité d'utiliser la quasi totalité de python (par exemple getattr et setattr). Ces hypothèses étant faites, je ne vois pas d'autre solution

...