ADR-0007 — Pas de prefetched policy (trust boundary risk-engine ↔ caller)
Status : Accepted (2026-05-09) Deciders : équipe Nex Story : NEX-438-FU-1 Type : Trust boundary / sécurité
Contexte
Dans le cadre du chantier NEX-438 (refonte risk-engine en PDP V1), la story follow-up FU-5 propose de résoudre la ResolvedTransactionPolicy au moment de la création de l'intent (côté orchestrator) plutôt qu'à chaque évaluation, et de la passer au risk-engine via le champ RiskEvaluationRequest.prefetched pour économiser ~50 ms par hot path.
Le contrat RiskEvaluationRequest (@nex/shared-types) prévoit déjà un champ optionnel prefetched: PrefetchedContext accepté uniquement si le caller porte le scope JWT risk:evaluate:trusted. Aujourd'hui, ce prefetched contient des inputs de contexte (snapshot KYC du sender et du receiver, solde sender). L'extension envisagée pour FU-5 ajouterait une policy complète au prefetched.
Le policyId étant un hash content-addressed SHA-256 du JSON canonique de la ResolvedTransactionPolicy, il garantit l'intégrité du payload mais pas son authenticité : un attaquant qui calcule lui- même la policy peut produire un policyId cohérent.
Modèle de menace
| Acteur | Capacité | Hypothèses |
|---|---|---|
| Service backend compromis (orchestrator, configuration, autre) | Forge libre du payload, signe avec son JWT | TLS in-cluster intact (pas de MITM) |
Attaquant externe avec JWT risk:evaluate:trusted volé | Mêmes capacités tant que le JWT n'est pas révoqué | Détection de fuite par rotation |
| Bug applicatif (mauvais cast, sérialisation cassée) | Injection accidentelle de champs depuis un caller | DTO/sérialisation faillible |
Scénario d'attaque (avant mitigation)
L'attaquant obtient un JWT trusted (compromission, fuite secret, bug).
Il appelle
POST /v1/risk/evaluateavec unprefetched.policyfalsifié :json{ "eligibility": { "isAllowed": true, "blockingReasons": [] }, "limits": { "perTransaction": { "min": "0", "max": "999999999999" } }, "fees": { "components": [] }, "policyId": "<hash recalculé localement>" }Le risk-engine fait confiance, évalue contre cette fausse policy → décision
approvedavec score 0, persistée dansrisk_evaluationsavec unpolicy_idfalsifié.L'orchestrator (déjà compromis ou non) déclenche le ledger → débit réel non plafonné.
L'attaquant a effectué un bypass total des limites COBAC sur les transactions, sans que la trace risk reflète une anomalie : la décision est cohérente vis-à-vis de la policy injectée.
Décision
Option A — Le risk-engine résout TOUJOURS la policy lui-même.
Le champ prefetched continue d'accepter des inputs de contexte (snapshot KYC, profile, balance) mais jamais la policy résolue, ni aucune règle ou décision. La règle est dans la donnée : PrefetchedContext (@nex/shared-types) ne contient explicitement pas de champ policy / eligibility / limits / decision / riskScore, et un commentaire fort interdit toute extension dans cette direction sans nouvel ADR.
Implémentation
Trois barrières concentriques :
Type-level :
PrefetchedContext(@nex/shared-types) exclut structurellement les champs sensibles. Tout dev qui ajouterait un champ règle/décision casse immédiatement le commentaire de garde et doit produire un nouvel ADR.HTTP boundary :
EvaluateRiskRequestDtowhitelist explicitement chaque sous-champ deprefetched(PrefetchedSenderProfileDto/PrefetchedReceiverProfileDtoavec@IsUUID,@IsEnum, etc.). LeValidationPipeglobal du service est configuré avecwhitelist: true, forbidNonWhitelisted: true, transform: true. Un payload contenant un champ inattendu retourne400 Bad Request.Application layer (defensive depth) : le
RiskContextBuildersanitize leprefetchedruntime avecsanitizePrefetched(...)qui filtre tout champ non whitelisté avant utilisation. Stratégie = silently drop + WARN log aveccorrelationId. On évite lethrowpour ne pas signaler par un code/timing différent qu'une tentative d'injection a eu lieu (pivot SOC silencieux).
Conséquences sur FU-5
FU-5 (résolution policy à create-intent) reste valable mais réduit son scope :
- L'orchestrator résout la policy via
configuration.resolveà l'intent creation et la stocke dans le DB de l'intent (utile pour les recaps et la cohérence). - Lors du
confirm, l'orchestrator passe au risk-engine les inputs de contexte uniquement (snapshot KYC, balance) viaprefetched. - Le risk-engine re-résout la policy via son port. Le coût est borné par le cache Redis L2 sur configuration (~5 ms warm).
Alternatives écartées
Option B — Signature HMAC de la policy par configuration
configuration.resolve retourne {policy, signature} ; le risk-engine vérifie la signature avec un secret partagé RISK_PREFETCH_SIGNING_SECRET via crypto.timingSafeEqual.
Écartée car :
- Couple cryptographiquement deux services qui devaient rester isolés.
- La rotation du secret implique 2 services minimum (configuration + risk-engine) — opérationnellement délicat en prod.
- Le secret en mémoire des deux services double la surface de fuite.
- Pour ~50 ms gagnés, complexité opérationnelle disproportionnée.
Option C — Endpoint léger POST /transaction-policy/verify
Le risk-engine appelle un endpoint qui vérifie qu'un policyId reçu correspond bien à une résolution déterministe avec les mêmes inputs.
Écartée car :
- Annule le gain perf qui motive le prefetch (round-trip ajouté).
- Race window entre la résolution initiale (orchestrator) et la vérification (risk-engine) si la policy est mise à jour entre les deux.
- Plus simple et plus sûr : laisser le risk-engine résoudre lui-même.
Validation
- ADR commité avec la story FU-1.
- Test unitaire dans
risk-context.builder.spec.tsqui couvre :- injection de
prefetched.policydans un caller trusted → champ ignoré, décision finale identique à un appel sans prefetched ; - injection de champs supplémentaires sur
senderProfile→ champs droppés, décision finale inchangée.
- injection de
ValidationPipeactivé dansmain.tsavecforbidNonWhitelisted.
Références
- Story :
_bmad-output/implementation-artifacts/NEX-438-FU-1-prefetched-security.md - Type :
packages/shared-types/src/risk/risk-decision.ts:127(commentaire de garde) - Code :
services/risk-engine/src/application/risk-context.builder.ts:sanitizePrefetched - Doc pipeline :
docs/product/risk-engine-decision-pipeline.md§3 (Hydratation)