feat(security): eval()→AST parseur + pickle→JSON+HMAC signé

Vulnérabilité 1 — eval() dans DAG executor :
- Nouveau module safe_condition_evaluator.py
- Parseur AST avec whitelist (Constants, Names, Compare, BoolOp, BinOp)
- Rejet explicite Call/Lambda/Import/__dunder__/walrus/comprehensions
- Expression non sûre → logged ERROR + évaluée à False (pas de crash)
- 31 tests (12 valides, 17 malveillantes rejetées, 2 intégration)

Vulnérabilité 2 — 3× pickle.load() non sécurisés :
- Nouveau module signed_serializer.py (JSON+HMAC-SHA256)
- Format : RPA_SIGNED_V1\\n + JSON(hmac + payload base64)
- Migration automatique transparente au premier chargement
- Fallback pickle avec WARNING (désactivable RPA_ALLOW_PICKLE_FALLBACK=0)
- Remplacement dans faiss_manager, visual_embedding_manager,
  visual_persistence_manager
- 13 tests

Clé signature : RPA_SIGNING_KEY (fallback TOKEN_SECRET_KEY puis hostname-derived).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:49:17 +02:00
parent 93ef93e563
commit 36737cfe9d
8 changed files with 1110 additions and 50 deletions

View File

@@ -525,11 +525,25 @@ class DAGExecutor:
True/False selon le résultat de la condition
"""
condition = action.get("condition", "True")
# Contexte d'évaluation sécurisé : uniquement les résultats
# Contexte d'évaluation sécurisé : uniquement les résultats.
# NB : on utilise un évaluateur AST restreint (pas d'eval/exec),
# seuls literals, comparaisons, booléens et indexations sont permis.
eval_context = {"results": dict(self._results)}
# Import local pour éviter une dépendance circulaire au chargement.
from core.execution.safe_condition_evaluator import (
UnsafeExpressionError,
safe_eval_condition,
)
try:
result = bool(eval(condition, {"__builtins__": {}}, eval_context))
result = bool(safe_eval_condition(condition, eval_context))
except UnsafeExpressionError as exc:
logger.error(
"Condition refusée pour '%s' (expression non sûre) : %s",
step.step_id, exc,
)
result = False
except Exception as exc:
logger.warning(
"Erreur d'évaluation de condition pour '%s' : %s",

View File

@@ -0,0 +1,228 @@
"""
Évaluateur de conditions sécurisé pour le DAGExecutor.
Remplace `eval()` (vulnérable à l'exécution de code arbitraire) par un
parseur AST restreint :
- Seuls les noeuds AST nécessaires sont autorisés (literals, comparaisons,
booléens, indexations, accès attribut limité, arithmétique simple).
- Les appels de fonction sont interdits.
- Les accès à des attributs « dunder » (`__class__`, `__import__`, etc.)
sont systématiquement refusés pour éviter les évasions classiques.
- Le contexte d'évaluation est fourni explicitement par l'appelant ;
aucun builtins n'est exposé.
Usage typique :
>>> evaluator = SafeConditionEvaluator()
>>> evaluator.evaluate("results['step_1']['score'] >= 0.8",
... {"results": {"step_1": {"score": 0.92}}})
True
"""
from __future__ import annotations
import ast
import operator
from typing import Any, Callable, Dict, Mapping
class UnsafeExpressionError(ValueError):
"""Levée lorsqu'une expression contient un noeud AST interdit."""
# Opérateurs arithmétiques & de comparaison autorisés.
_BIN_OPS: Dict[type, Callable[[Any, Any], Any]] = {
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,
}
_BOOL_OPS: Dict[type, Callable[[Any, Any], Any]] = {
ast.And: lambda a, b: a and b,
ast.Or: lambda a, b: a or b,
}
_UNARY_OPS: Dict[type, Callable[[Any], Any]] = {
ast.Not: operator.not_,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
_CMP_OPS: Dict[type, Callable[[Any, Any], bool]] = {
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.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
}
class SafeConditionEvaluator:
"""Évalue une expression de condition via un parseur AST restreint."""
# Longueur max — stoppe les expressions pathologiques très tôt.
MAX_EXPRESSION_LENGTH = 1024
def evaluate(
self,
expression: str,
context: Mapping[str, Any],
) -> Any:
if not isinstance(expression, str):
raise UnsafeExpressionError(
"L'expression doit être une chaîne de caractères."
)
if len(expression) > self.MAX_EXPRESSION_LENGTH:
raise UnsafeExpressionError(
"Expression trop longue (> 1024 caractères)."
)
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise UnsafeExpressionError(
f"Syntaxe d'expression invalide : {exc}"
) from exc
return self._eval_node(tree.body, context)
# ------------------------------------------------------------------
# Dispatch AST
# ------------------------------------------------------------------
def _eval_node(self, node: ast.AST, context: Mapping[str, Any]) -> Any:
# Littéraux (Constant remplace Num/Str/Bytes/NameConstant depuis 3.8)
if isinstance(node, ast.Constant):
return node.value
# Variables : uniquement celles présentes dans `context`.
if isinstance(node, ast.Name):
if node.id not in context:
raise UnsafeExpressionError(
f"Variable '{node.id}' non autorisée."
)
return context[node.id]
# Accès attribut — interdit tout attribut dunder.
if isinstance(node, ast.Attribute):
if node.attr.startswith("_"):
raise UnsafeExpressionError(
f"Accès à l'attribut privé '{node.attr}' interdit."
)
value = self._eval_node(node.value, context)
return getattr(value, node.attr)
# Indexation (results['step_1']).
if isinstance(node, ast.Subscript):
value = self._eval_node(node.value, context)
# Python < 3.9 utilise ast.Index, >= 3.9 utilise directement un
# noeud. On gère les deux cas.
slice_node = node.slice
if isinstance(slice_node, ast.Index): # type: ignore[attr-defined]
slice_value = self._eval_node(
slice_node.value, context # type: ignore[attr-defined]
)
else:
slice_value = self._eval_node(slice_node, context)
return value[slice_value]
# Comparaisons chaînées (a < b <= c).
if isinstance(node, ast.Compare):
left = self._eval_node(node.left, context)
for op_node, comparator in zip(node.ops, node.comparators):
op_cls = type(op_node)
if op_cls not in _CMP_OPS:
raise UnsafeExpressionError(
f"Opérateur de comparaison '{op_cls.__name__}' interdit."
)
right = self._eval_node(comparator, context)
if not _CMP_OPS[op_cls](left, right):
return False
left = right
return True
# Booléen (and / or) — short-circuit manuel.
if isinstance(node, ast.BoolOp):
op_cls = type(node.op)
if op_cls not in _BOOL_OPS:
raise UnsafeExpressionError(
f"Opérateur booléen '{op_cls.__name__}' interdit."
)
if isinstance(node.op, ast.And):
result: Any = True
for sub in node.values:
result = self._eval_node(sub, context)
if not result:
return result
return result
# Or
result = False
for sub in node.values:
result = self._eval_node(sub, context)
if result:
return result
return result
# Unaires (-x, not x)
if isinstance(node, ast.UnaryOp):
op_cls = type(node.op)
if op_cls not in _UNARY_OPS:
raise UnsafeExpressionError(
f"Opérateur unaire '{op_cls.__name__}' interdit."
)
return _UNARY_OPS[op_cls](self._eval_node(node.operand, context))
# Binaires (+, -, *, /, %, **, //)
if isinstance(node, ast.BinOp):
op_cls = type(node.op)
if op_cls not in _BIN_OPS:
raise UnsafeExpressionError(
f"Opérateur binaire '{op_cls.__name__}' interdit."
)
left = self._eval_node(node.left, context)
right = self._eval_node(node.right, context)
return _BIN_OPS[op_cls](left, right)
# Literals composites
if isinstance(node, ast.Tuple):
return tuple(self._eval_node(e, context) for e in node.elts)
if isinstance(node, ast.List):
return [self._eval_node(e, context) for e in node.elts]
if isinstance(node, ast.Set):
return {self._eval_node(e, context) for e in node.elts}
if isinstance(node, ast.Dict):
return {
self._eval_node(k, context) if k is not None else None:
self._eval_node(v, context)
for k, v in zip(node.keys, node.values)
}
# Tout le reste (Call, Lambda, Comprehensions, Import, etc.) est
# refusé explicitement.
raise UnsafeExpressionError(
f"Noeud AST '{type(node).__name__}' interdit dans les conditions."
)
def safe_eval_condition(
expression: str,
context: Mapping[str, Any],
) -> Any:
"""Helper fonctionnel : évalue `expression` avec le contexte donné."""
return SafeConditionEvaluator().evaluate(expression, context)
__all__ = [
"SafeConditionEvaluator",
"UnsafeExpressionError",
"safe_eval_condition",
]