Skip to content
acceptedAudienceSécuritéDevAudit banqueOwner@security-teamDernière revue2026-05-21

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

ActeurCapacitéHypothèses
Service backend compromis (orchestrator, configuration, autre)Forge libre du payload, signe avec son JWTTLS 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 callerDTO/sérialisation faillible

Scénario d'attaque (avant mitigation)

  1. L'attaquant obtient un JWT trusted (compromission, fuite secret, bug).

  2. Il appelle POST /v1/risk/evaluate avec un prefetched.policy falsifié :

    json
    {
      "eligibility": { "isAllowed": true, "blockingReasons": [] },
      "limits": { "perTransaction": { "min": "0", "max": "999999999999" } },
      "fees": { "components": [] },
      "policyId": "<hash recalculé localement>"
    }
  3. Le risk-engine fait confiance, évalue contre cette fausse policy → décision approved avec score 0, persistée dans risk_evaluations avec un policy_id falsifié.

  4. 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 :

  1. 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.

  2. HTTP boundary : EvaluateRiskRequestDto whitelist explicitement chaque sous-champ de prefetched (PrefetchedSenderProfileDto / PrefetchedReceiverProfileDto avec @IsUUID, @IsEnum, etc.). Le ValidationPipe global du service est configuré avec whitelist: true, forbidNonWhitelisted: true, transform: true. Un payload contenant un champ inattendu retourne 400 Bad Request.

  3. Application layer (defensive depth) : le RiskContextBuilder sanitize le prefetched runtime avec sanitizePrefetched(...) qui filtre tout champ non whitelisté avant utilisation. Stratégie = silently drop + WARN log avec correlationId. On évite le throw pour 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) via prefetched.
  • 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.ts qui couvre :
    • injection de prefetched.policy dans 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.
  • ValidationPipe activé dans main.ts avec forbidNonWhitelisted.

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)

Nex — Plateforme fintech CEMAC