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 !