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>
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""
|
|
Sérialiseur signé — RPA Vision V3
|
|
|
|
Remplace les usages de `pickle.load` (vulnérables à la désérialisation arbitraire
|
|
de code) par une sérialisation JSON signée via HMAC-SHA256.
|
|
|
|
Principes :
|
|
- Les données sont sérialisées en JSON (avec support des types numpy / datetime
|
|
via un encodeur custom).
|
|
- Une signature HMAC-SHA256 est calculée sur le JSON avec une clé secrète
|
|
dérivée de `RPA_SIGNING_KEY` (ou, à défaut, de `TOKEN_SECRET_KEY`).
|
|
- À la lecture, la signature est vérifiée AVANT tout parsing applicatif.
|
|
- Rétrocompatibilité : un fallback `pickle.load` est disponible pour migrer
|
|
les anciens fichiers. Il logue un WARNING et doit être suivi d'une
|
|
ré-écriture en JSON signé.
|
|
|
|
ATTENTION : n'utiliser le fallback pickle que sur des fichiers dont la source
|
|
est réputée sûre (locale + protégée). Le fallback est désactivable via la
|
|
variable d'environnement `RPA_ALLOW_PICKLE_FALLBACK=0`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import pickle
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional, Union
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Clé de signature
|
|
# -----------------------------------------------------------------------------
|
|
|
|
_SIGNATURE_ALGO = "sha256"
|
|
_SIGNATURE_HEADER = b"RPA_SIGNED_V1\n" # Marqueur de format signé
|
|
|
|
|
|
def _resolve_signing_key() -> bytes:
|
|
"""Récupère la clé de signature HMAC.
|
|
|
|
Ordre de priorité :
|
|
1. RPA_SIGNING_KEY (dédiée à la signature de fichiers)
|
|
2. TOKEN_SECRET_KEY (clé déjà utilisée pour signer les tokens API)
|
|
3. Clé dérivée en dev (avec WARNING)
|
|
|
|
La clé dev est stable pour une même machine (dérivée du hostname + path)
|
|
afin que les lectures/écritures locales restent cohérentes en l'absence
|
|
de configuration, tout en refusant de valider des fichiers produits
|
|
ailleurs.
|
|
"""
|
|
explicit = os.getenv("RPA_SIGNING_KEY", "").strip()
|
|
if explicit:
|
|
return explicit.encode("utf-8")
|
|
|
|
fallback = os.getenv("TOKEN_SECRET_KEY", "").strip()
|
|
if fallback:
|
|
return fallback.encode("utf-8")
|
|
|
|
# Clé dev dérivée : non cryptographiquement sûre, juste pour éviter des
|
|
# erreurs en dev local. On loggue explicitement.
|
|
logger.warning(
|
|
"RPA_SIGNING_KEY et TOKEN_SECRET_KEY non définis — "
|
|
"utilisation d'une clé dérivée locale. "
|
|
"Définir RPA_SIGNING_KEY en production."
|
|
)
|
|
seed = f"rpa-vision-v3::{os.uname().nodename}::dev-signing" # type: ignore[attr-defined]
|
|
return hashlib.sha256(seed.encode("utf-8")).digest()
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Encodage JSON étendu (numpy, datetime, Path, bytes)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
class _RPAJSONEncoder(json.JSONEncoder):
|
|
"""Encodeur JSON supportant numpy / datetime / Path / bytes."""
|
|
|
|
def default(self, obj: Any) -> Any: # noqa: D401 - API json standard
|
|
if isinstance(obj, np.ndarray):
|
|
return {
|
|
"__type__": "ndarray",
|
|
"dtype": str(obj.dtype),
|
|
"shape": list(obj.shape),
|
|
"data": base64.b64encode(obj.tobytes()).decode("ascii"),
|
|
}
|
|
if isinstance(obj, (np.integer,)):
|
|
return int(obj)
|
|
if isinstance(obj, (np.floating,)):
|
|
return float(obj)
|
|
if isinstance(obj, (np.bool_,)):
|
|
return bool(obj)
|
|
if isinstance(obj, datetime):
|
|
return {"__type__": "datetime", "iso": obj.isoformat()}
|
|
if isinstance(obj, timedelta):
|
|
return {"__type__": "timedelta", "seconds": obj.total_seconds()}
|
|
if isinstance(obj, Path):
|
|
return {"__type__": "path", "value": str(obj)}
|
|
if isinstance(obj, bytes):
|
|
return {
|
|
"__type__": "bytes",
|
|
"data": base64.b64encode(obj).decode("ascii"),
|
|
}
|
|
if isinstance(obj, set):
|
|
return {"__type__": "set", "items": list(obj)}
|
|
return super().default(obj)
|
|
|
|
|
|
def _json_object_hook(obj: Any) -> Any:
|
|
"""Reconstruit les types étendus depuis le JSON."""
|
|
if not isinstance(obj, dict):
|
|
return obj
|
|
tag = obj.get("__type__")
|
|
if tag is None:
|
|
return obj
|
|
if tag == "ndarray":
|
|
raw = base64.b64decode(obj["data"])
|
|
arr = np.frombuffer(raw, dtype=np.dtype(obj["dtype"]))
|
|
return arr.reshape(obj["shape"]).copy()
|
|
if tag == "datetime":
|
|
return datetime.fromisoformat(obj["iso"])
|
|
if tag == "timedelta":
|
|
return timedelta(seconds=float(obj["seconds"]))
|
|
if tag == "path":
|
|
return Path(obj["value"])
|
|
if tag == "bytes":
|
|
return base64.b64decode(obj["data"])
|
|
if tag == "set":
|
|
return set(obj.get("items", []))
|
|
return obj
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Erreurs dédiées
|
|
# -----------------------------------------------------------------------------
|
|
|
|
class SignedSerializerError(Exception):
|
|
"""Erreur de base du module."""
|
|
|
|
|
|
class SignatureVerificationError(SignedSerializerError):
|
|
"""Signature HMAC invalide : le fichier a été altéré ou forgé."""
|
|
|
|
|
|
class UnsupportedFormatError(SignedSerializerError):
|
|
"""Le fichier n'est ni au format signé, ni reconnu comme pickle legacy."""
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# API publique
|
|
# -----------------------------------------------------------------------------
|
|
|
|
def _compute_hmac(payload: bytes, key: bytes) -> str:
|
|
return hmac.new(key, payload, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def dumps_signed(data: Any, key: Optional[bytes] = None) -> bytes:
|
|
"""Sérialise `data` en JSON signé HMAC-SHA256.
|
|
|
|
Format binaire retourné :
|
|
b"RPA_SIGNED_V1\n" + utf8(json({"hmac": "<hex>", "payload": <data>}))
|
|
|
|
Le HMAC couvre le JSON canonique de `payload` (keys triées,
|
|
séparateurs compacts) pour qu'un même objet produise toujours la
|
|
même signature.
|
|
"""
|
|
signing_key = key if key is not None else _resolve_signing_key()
|
|
payload_json = json.dumps(
|
|
data,
|
|
cls=_RPAJSONEncoder,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
signature = _compute_hmac(payload_json, signing_key)
|
|
envelope = {"hmac": signature, "payload_b64": base64.b64encode(payload_json).decode("ascii")}
|
|
body = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
return _SIGNATURE_HEADER + body
|
|
|
|
|
|
def loads_signed(raw: bytes, key: Optional[bytes] = None) -> Any:
|
|
"""Désérialise un blob produit par `dumps_signed` après vérification HMAC."""
|
|
if not raw.startswith(_SIGNATURE_HEADER):
|
|
raise UnsupportedFormatError("Marqueur RPA_SIGNED_V1 absent.")
|
|
signing_key = key if key is not None else _resolve_signing_key()
|
|
body = raw[len(_SIGNATURE_HEADER):]
|
|
try:
|
|
envelope = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SignedSerializerError(f"Enveloppe JSON invalide : {exc}") from exc
|
|
|
|
if not isinstance(envelope, dict):
|
|
raise SignedSerializerError("Enveloppe inattendue.")
|
|
signature = envelope.get("hmac")
|
|
payload_b64 = envelope.get("payload_b64")
|
|
if not isinstance(signature, str) or not isinstance(payload_b64, str):
|
|
raise SignedSerializerError("Enveloppe mal formée (hmac / payload_b64).")
|
|
|
|
try:
|
|
payload_bytes = base64.b64decode(payload_b64.encode("ascii"), validate=True)
|
|
except Exception as exc: # noqa: BLE001 - base64 peut lever plusieurs erreurs
|
|
raise SignedSerializerError(f"Payload base64 invalide : {exc}") from exc
|
|
|
|
expected = _compute_hmac(payload_bytes, signing_key)
|
|
if not hmac.compare_digest(expected, signature):
|
|
raise SignatureVerificationError(
|
|
"Signature HMAC invalide — fichier altéré ou clé différente."
|
|
)
|
|
|
|
return json.loads(payload_bytes.decode("utf-8"), object_hook=_json_object_hook)
|
|
|
|
|
|
def _pickle_fallback_allowed() -> bool:
|
|
return os.getenv("RPA_ALLOW_PICKLE_FALLBACK", "1") != "0"
|
|
|
|
|
|
def save_signed(path: Union[str, Path], data: Any, key: Optional[bytes] = None) -> None:
|
|
"""Écrit `data` sur disque dans le format JSON signé."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
blob = dumps_signed(data, key=key)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
with open(tmp, "wb") as fp:
|
|
fp.write(blob)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def load_signed(
|
|
path: Union[str, Path],
|
|
*,
|
|
allow_pickle_fallback: bool = True,
|
|
migrate_on_fallback: bool = True,
|
|
pickle_loader: Optional[Callable[[io.BufferedReader], Any]] = None,
|
|
key: Optional[bytes] = None,
|
|
) -> Any:
|
|
"""Charge un fichier sauvegardé par `save_signed`.
|
|
|
|
Si le fichier n'est pas au format signé, et si `allow_pickle_fallback`
|
|
est vrai (ET `RPA_ALLOW_PICKLE_FALLBACK != "0"`), tente un
|
|
`pickle.load()` pour migrer les anciens fichiers. Dans ce cas, un
|
|
WARNING est émis et le fichier est ré-écrit en JSON signé si
|
|
`migrate_on_fallback` vaut True.
|
|
|
|
Args:
|
|
path: Chemin du fichier
|
|
allow_pickle_fallback: Activer la compat legacy
|
|
migrate_on_fallback: Ré-écrire en JSON signé après fallback
|
|
pickle_loader: Callable alternatif (pour tests / restricted unpickler)
|
|
key: Clé HMAC explicite (sinon dérivée de l'environnement)
|
|
|
|
Raises:
|
|
SignatureVerificationError: HMAC invalide (fichier altéré)
|
|
UnsupportedFormatError: format inconnu et fallback désactivé
|
|
"""
|
|
path = Path(path)
|
|
with open(path, "rb") as fp:
|
|
raw = fp.read()
|
|
|
|
if raw.startswith(_SIGNATURE_HEADER):
|
|
return loads_signed(raw, key=key)
|
|
|
|
if not allow_pickle_fallback or not _pickle_fallback_allowed():
|
|
raise UnsupportedFormatError(
|
|
f"{path} n'est pas au format signé et le fallback pickle est désactivé."
|
|
)
|
|
|
|
logger.warning(
|
|
"Chargement legacy pickle pour %s — ce format est obsolète et "
|
|
"sera ré-écrit en JSON signé. Voir docs/SECURITY.md.",
|
|
path,
|
|
)
|
|
|
|
# Par défaut on refuse tout type non documenté dans ce fichier à risque :
|
|
# utilisateur peut fournir un `pickle_loader` custom (ex: Unpickler
|
|
# restreint). On log l'ouverture pour la traçabilité.
|
|
loader = pickle_loader or (lambda f: pickle.load(f)) # noqa: S301 - usage legacy
|
|
with open(path, "rb") as fp:
|
|
data = loader(fp)
|
|
|
|
if migrate_on_fallback:
|
|
try:
|
|
save_signed(path, data, key=key)
|
|
logger.info("Fichier %s migré en JSON signé.", path)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error(
|
|
"Migration JSON signé échouée pour %s : %s", path, exc
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
__all__ = [
|
|
"SignedSerializerError",
|
|
"SignatureVerificationError",
|
|
"UnsupportedFormatError",
|
|
"dumps_signed",
|
|
"loads_signed",
|
|
"save_signed",
|
|
"load_signed",
|
|
]
|