@nodefony/security 10.0.0-beta.6 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +17 -6
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +3 -1
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/service/accessTokenVerifier.js +1 -1
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +6 -3
- package/dist/nodefony/service/firewall.js +48 -3
- package/dist/nodefony/service/tokenService.js +8 -3
- package/dist/nodefony/service/totp.js +7 -2
- package/dist/nodefony/service/webAuthn.js +45 -6
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/types/index.d.ts +4 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +6 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/docs/audit.md +112 -28
- package/docs/keycloak.md +4 -4
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/webhooks.md +15 -7
- package/package.json +13 -13
package/docs/audit.md
CHANGED
|
@@ -21,7 +21,7 @@ tags:
|
|
|
21
21
|
]
|
|
22
22
|
version: "doc"
|
|
23
23
|
status: stable
|
|
24
|
-
updated: 2026-
|
|
24
|
+
updated: 2026-11-13
|
|
25
25
|
source: "src/packages/@nodefony/security/docs/audit.md"
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -87,6 +87,7 @@ webhooks, console — se branche autour de lui.
|
|
|
87
87
|
| Append-only | On n'ajoute que des lignes ; rien n'est modifié ni supprimé, sauf la purge de rétention. |
|
|
88
88
|
| Rétention | Durée pendant laquelle un événement reste consultable avant purge (`retentionDays`). |
|
|
89
89
|
| GC | _Garbage collection_ : la purge périodique des événements hors rétention. |
|
|
90
|
+
| Agrégation | Un flot de refus d'une même source s'écrit en quelques lignes, puis une ligne de **synthèse**. |
|
|
90
91
|
| Curseur | Jeton opaque « donne-moi la suite après celui-ci » — la façon de paginer un flux qui grandit. |
|
|
91
92
|
| Cold-path | Chemin d'exception (échec, refus). Par opposition au **hot-path** : le chemin nominal, à coût nul. |
|
|
92
93
|
| Fire-and-forget | On lance l'écriture sans l'attendre : le flux métier ne dépend pas de son succès. |
|
|
@@ -246,7 +247,7 @@ sans qu'on l'ait demandée (IP, User-Agent, `requestId`), et les `flags` disent
|
|
|
246
247
|
|
|
247
248
|
Le même journal est consultable dans **Studio → Sécurité → Journal d'audit**.
|
|
248
249
|
|
|
249
|
-
## ⚙️
|
|
250
|
+
## ⚙️ Cinq situations — du besoin à la config
|
|
250
251
|
|
|
251
252
|
### Situation 1 — « Qui s'est connecté à ce compte cette nuit ? »
|
|
252
253
|
|
|
@@ -327,7 +328,70 @@ Le store mémoire porte une **seconde** protection, indépendante du temps : un
|
|
|
327
328
|
pas une politique de rétention, c'est un garde-fou anti-fuite : la mémoire d'un pod est bornée quoi
|
|
328
329
|
qu'il arrive.
|
|
329
330
|
|
|
330
|
-
### Situation 4 — «
|
|
331
|
+
### Situation 4 — « Un flot de refus ne doit pas noyer la base »
|
|
332
|
+
|
|
333
|
+
Un robot essaie des mots de passe à 1 000 requêtes par seconde. Une ligne par refus, c'est
|
|
334
|
+
86 millions de lignes par jour, conservées toute la rétention — et l'OWASP Logging Cheat Sheet
|
|
335
|
+
demande justement que la journalisation n'épuise ni les ressources ni la base.
|
|
336
|
+
|
|
337
|
+
Rien à configurer : les **refus** (`failure` et `denied`) sont agrégés par défaut
|
|
338
|
+
(`AuditRefusalAggregator.ts#AuditRefusalAggregator`). Pour une même **source** — même catégorie,
|
|
339
|
+
action, issue, motif, ressource et **IP** — et par fenêtre de 60 secondes ouverte au premier refus :
|
|
340
|
+
|
|
341
|
+
1. les **10 premiers** refus sont écrits tels quels, acteur compris : l'enquêteur voit les premiers
|
|
342
|
+
essais et les comptes visés ;
|
|
343
|
+
2. les suivants sont **comptés**, ni écrits ni diffusés en direct
|
|
344
|
+
(`AuditRefusalAggregator.ts#AuditRefusalAggregator.admit`) ;
|
|
345
|
+
3. la fenêtre close écrit **une** ligne de synthèse qui porte le compte.
|
|
346
|
+
|
|
347
|
+
```json
|
|
348
|
+
{
|
|
349
|
+
"category": "auth",
|
|
350
|
+
"action": "auth.failure",
|
|
351
|
+
"outcome": "failure",
|
|
352
|
+
"actor": null,
|
|
353
|
+
"resource": "api",
|
|
354
|
+
"reason": "invalid_credentials",
|
|
355
|
+
"ip": "203.0.113.7",
|
|
356
|
+
"metadata": {
|
|
357
|
+
"aggregated": {
|
|
358
|
+
"count": 59990,
|
|
359
|
+
"firstTs": 1763548800450,
|
|
360
|
+
"lastTs": 1763548859998,
|
|
361
|
+
"windowMs": 60000
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Trois choix à connaître :
|
|
368
|
+
|
|
369
|
+
- **L'IP est celle du contexte**, résolue derrière un proxy de confiance (`trustProxy`), donc non
|
|
370
|
+
forgeable par le client (`readAuditContext.ts#readAuditContext`).
|
|
371
|
+
- **L'acteur n'entre pas dans la clé.** Un bourrage d'identifiants change de compte à chaque essai :
|
|
372
|
+
c'est sa source qui le trahit. La synthèse porte `actor: null` plutôt qu'un acteur choisi au hasard.
|
|
373
|
+
- **Les succès ne sont jamais agrégés** : un login réussi reste une ligne, toujours.
|
|
374
|
+
|
|
375
|
+
La mémoire est bornée : au-delà de 10 000 sources suivies, les refus se regroupent sous l'IP `*`.
|
|
376
|
+
Le balayage des fenêtres échues n'est armé qu'au premier refus et s'arrête quand la table est vide
|
|
377
|
+
(`auditService.ts#AuditService.#startSweep`) ; à l'arrêt du serveur, les comptes en cours sont résumés
|
|
378
|
+
avant que le store ne soit lâché (`auditService.ts#AuditService.#shutdown`).
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
use("@nodefony/security", {
|
|
382
|
+
audit: {
|
|
383
|
+
// Plus de lignes détaillées par source, sur une fenêtre plus courte.
|
|
384
|
+
aggregate: { burst: 50, windowMs: 10_000 },
|
|
385
|
+
// Ou une ligne par refus, sans exception :
|
|
386
|
+
// aggregate: { enabled: false },
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Ce qu'on observe : pour lister les flots, filtrer sur l'issue et repérer les événements dont
|
|
392
|
+
`metadata.aggregated` est présent ; leur `ip` désigne la source.
|
|
393
|
+
|
|
394
|
+
### Situation 5 — « Ne rien perdre quand le store tombe »
|
|
331
395
|
|
|
332
396
|
La base est indisponible pendant trente secondes. Que se passe-t-il ?
|
|
333
397
|
|
|
@@ -453,7 +517,7 @@ garantit un seul appel d'horloge, centralisé hors des points d'émission.
|
|
|
453
517
|
| `userAgent` | `string \| null` | contexte | provenance client |
|
|
454
518
|
| `requestId` | `string \| null` | contexte | corrélation log ↔ audit ↔ trace |
|
|
455
519
|
| `flags` | `IAuditEventFlags` | contexte | **présence** de matériel sensible, jamais la valeur |
|
|
456
|
-
| `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres
|
|
520
|
+
| `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres ; `aggregated` sur une ligne de synthèse |
|
|
457
521
|
|
|
458
522
|
Les quatre derniers champs de provenance sont remplis d'un coup par `readAuditContext()`
|
|
459
523
|
(`readAuditContext.ts#readAuditContext`), qui lit un contexte HTTP ou WS de façon défensive — tous les champs sont
|
|
@@ -462,7 +526,7 @@ optionnels, un contexte partiel ne casse rien.
|
|
|
462
526
|
## ⚡ Performance & mémoire — pourquoi l'audit ne pèse pas sur la requête
|
|
463
527
|
|
|
464
528
|
C'est un point d'honneur du framework : **l'audit ne se paie que quand il se passe quelque chose**.
|
|
465
|
-
|
|
529
|
+
Six mécanismes, tous prouvés par les tests.
|
|
466
530
|
|
|
467
531
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
468
532
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
@@ -477,7 +541,17 @@ aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens
|
|
|
477
541
|
**3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
|
|
478
542
|
(`auditService.ts#AuditService.record`). La latence du store n'entre jamais dans la latence de la requête.
|
|
479
543
|
|
|
480
|
-
**4.
|
|
544
|
+
**4. Les écritures SQL sont groupées.** Le store Drizzle accumule les événements d'un tour de boucle
|
|
545
|
+
et les écrit en un seul `INSERT` multi-lignes au tour suivant (`DrizzleAuditStore.ts#DrizzleAuditStore.append`) ;
|
|
546
|
+
une lecture ou une purge vide d'abord le lot en attente, donc on relit toujours ce qu'on vient
|
|
547
|
+
d'écrire. Mesuré sur un serveur de production local : un refus coûtait plus cher qu'un appel réussi
|
|
548
|
+
quand chaque événement avait son `INSERT` ; groupé, il revient au même ordre de grandeur.
|
|
549
|
+
|
|
550
|
+
**5. Un flot de refus se résume.** Au-delà de quelques refus d'une même source par fenêtre, les
|
|
551
|
+
suivants sont comptés au lieu d'être écrits (voir la situation 4). Le coût d'un refus compté se
|
|
552
|
+
réduit à une recherche dans une table bornée.
|
|
553
|
+
|
|
554
|
+
**6. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
|
|
481
555
|
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts#AuditService.subscribe`). Le pont
|
|
482
556
|
WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
|
|
483
557
|
alloué qu'au premier événement reçu (`auditBridge.ts#createAuditBridge`) ; son minuteur est armé à la demande et
|
|
@@ -494,13 +568,17 @@ jamais faire tomber ce qu'on supervise.
|
|
|
494
568
|
Table dérivée du schéma Zod `auditSchema` (`config.ts#auditSchema`), rattaché à la racine sous la clé `audit`
|
|
495
569
|
(`config.ts#securityConfigSchema`).
|
|
496
570
|
|
|
497
|
-
| Option
|
|
498
|
-
|
|
|
499
|
-
| `enabled`
|
|
500
|
-
| `store`
|
|
501
|
-
| `retentionDays`
|
|
502
|
-
| `immutable`
|
|
503
|
-
| `stream`
|
|
571
|
+
| Option | Type | Défaut | Effet |
|
|
572
|
+
| -------------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
573
|
+
| `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
|
|
574
|
+
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
|
|
575
|
+
| `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
|
|
576
|
+
| `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
577
|
+
| `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
578
|
+
| `aggregate.enabled` | `boolean` | `true` | agrège les refus d'une même source ; `false` → une ligne par refus |
|
|
579
|
+
| `aggregate.windowMs` | `number` | `60000` | durée d'une fenêtre, ouverte au premier refus d'une source |
|
|
580
|
+
| `aggregate.burst` | `number` | `10` | refus écrits tels quels par source et par fenêtre, avant le simple comptage |
|
|
581
|
+
| `aggregate.maxKeys` | `number` | `10000` | sources suivies au plus ; au-delà, regroupement sous l'IP `*` |
|
|
504
582
|
|
|
505
583
|
> [!WARNING]
|
|
506
584
|
> `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
|
|
@@ -626,8 +704,9 @@ filtrage, jamais de sémantique.
|
|
|
626
704
|
L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
|
|
627
705
|
(`registerStores.ts#registerMongooseFrameworkStores`, entité via `registerAuditEntities()`, `auditEventEntity.ts#registerAuditEntities`). Côté
|
|
628
706
|
implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts#DrizzleAuditStore`) résout son handle de base **à chaque
|
|
629
|
-
appel**, pas à la construction : l'ordre de démarrage n'est pas garanti
|
|
630
|
-
`onTerminate
|
|
707
|
+
appel**, pas à la construction : l'ordre de démarrage n'est pas garanti. À l'arrêt, l'audit vide son
|
|
708
|
+
agrégateur à `onTerminate`, après le drain des serveurs et avant la déconnexion de l'ORM : sa
|
|
709
|
+
libération s'inscrit à la résolution du store, donc après celle du connecteur (ADR-0027).
|
|
631
710
|
|
|
632
711
|
## 🔐 Sécurité — ce qui n'entre JAMAIS dans le journal
|
|
633
712
|
|
|
@@ -667,6 +746,7 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
667
746
|
| ----------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
668
747
|
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts#Firewall.describeRoleHierarchy`) |
|
|
669
748
|
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts#Authorization.#auditDeny`) |
|
|
749
|
+
| Ne pas épuiser les ressources ni la base | OWASP Logging Cheat Sheet | refus agrégés par source et par fenêtre (`AuditRefusalAggregator.ts#AuditRefusalAggregator`) |
|
|
670
750
|
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts#AuditOutcome`) |
|
|
671
751
|
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts#IAuditEvent`) |
|
|
672
752
|
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts#IAuditStore`) |
|
|
@@ -699,19 +779,20 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
699
779
|
|
|
700
780
|
## ⚠️ Pièges
|
|
701
781
|
|
|
702
|
-
| Symptôme | Cause (dans le code) | Correction
|
|
703
|
-
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
704
|
-
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts#MemoryAuditStore`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`)
|
|
705
|
-
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé
|
|
706
|
-
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts#AuditService.#build`) | Corriger le nom, ou charger l'adapter qui l'enregistre
|
|
707
|
-
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts#toPageParams`) | Paginer avec `nextCursor`, jamais gonfler `limit`
|
|
708
|
-
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque
|
|
709
|
-
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…)
|
|
710
|
-
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId`
|
|
711
|
-
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts#AuditService.#listeners`) | Retirer le rôle admin, ou ne pas exposer le canal
|
|
712
|
-
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts#AuditService.record`) | Store à haute disponibilité si la conformité l'exige
|
|
713
|
-
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul
|
|
714
|
-
|
|
|
782
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
783
|
+
| --------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
784
|
+
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts#MemoryAuditStore`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
|
|
785
|
+
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
786
|
+
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts#AuditService.#build`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
787
|
+
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts#toPageParams`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
788
|
+
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
|
|
789
|
+
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
790
|
+
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
791
|
+
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts#AuditService.#listeners`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
792
|
+
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts#AuditService.record`) | Store à haute disponibilité si la conformité l'exige |
|
|
793
|
+
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
|
|
794
|
+
| 10 lignes pour 1 000 échecs d'une même IP | Agrégation des refus (`AuditRefusalAggregator.ts#AuditRefusalAggregator`) | Lire la synthèse (`metadata.aggregated`), ou régler `aggregate` |
|
|
795
|
+
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts#createSecurityAdminApi`) | Réactiver l'audit en configuration |
|
|
715
796
|
|
|
716
797
|
## 🧪 Tests & couverture
|
|
717
798
|
|
|
@@ -723,6 +804,9 @@ Cinq familles couvrent la brique :
|
|
|
723
804
|
isolation d'un abonné qui lève) ; `auditStoreRegistry` (builtin enregistré à l'import, fabrique
|
|
724
805
|
défensive sur config partielle, backend inconnu) ; `auditBridge` (coalescing borné, nettoyage,
|
|
725
806
|
plancher `ROLE_NODEFONY_ADMIN` du canal).
|
|
807
|
+
- **unit — l'agrégation** : `auditRefusalAggregator` prouve qu'un flot d'une source s'écrit en
|
|
808
|
+
10 lignes plus une synthèse, que deux IP restent deux sources, que la mémoire est bornée, que
|
|
809
|
+
l'arrêt résume les comptes en cours et que les succès ne sont jamais agrégés.
|
|
726
810
|
- **unit — l'émission** : `auditEmission` prouve que `AuthFlow` et `Authorization` journalisent
|
|
727
811
|
**réellement** via le container partagé, exactement comme en production.
|
|
728
812
|
- **unit — le hot-path** : `auditEmissionHotPath` est le banc le plus instructif de la brique. Il
|
package/docs/keycloak.md
CHANGED
|
@@ -265,10 +265,10 @@ lui, en a un de test — Mailpit (`--profile keycloak`, interface `http://localh
|
|
|
265
265
|
processus dès le démarrage, commandes en ligne comprises (`addDefaultCaCertificate()`,
|
|
266
266
|
`certificates.ts#addDefaultCaCertificate`). La variable `NODE_EXTRA_CA_CERTS` n'est plus nécessaire.
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
service, `client_credentials`). Ses jetons portent l'audience de la zone `partner`
|
|
270
|
-
|
|
271
|
-
keycloak` obtient un jeton et appelle `GET /api/partner/whoami` dès le premier démarrage. Quand tu
|
|
268
|
+
Une application créée avec `--demo` reçoit aussi, comme le dépôt, un client **machine** `<app>-machine`
|
|
269
|
+
(compte de service, `client_credentials`). Ses jetons portent l'audience de la zone `demo-partner`
|
|
270
|
+
que la démo ouvre (`https://localhost:5152/demo/api/partner`) : `npx nodefony demo:partner:call
|
|
271
|
+
--issuer keycloak` obtient un jeton et appelle `GET /demo/api/partner/whoami` dès le premier démarrage. Quand tu
|
|
272
272
|
ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas),
|
|
273
273
|
`npx nodefony security:keycloak:realm --write` ajoute le mapper qui inscrit sa `resource` dans les
|
|
274
274
|
jetons.
|
package/docs/obtenir-un-jeton.md
CHANGED
|
@@ -125,15 +125,15 @@ Puis `npm run build` — le runtime lit le `dist`, pas la source.
|
|
|
125
125
|
|
|
126
126
|
### Les options, et ce que chacune décide
|
|
127
127
|
|
|
128
|
-
| Option | Défaut | Ce qu'elle change
|
|
129
|
-
| ---------------------- | -------------------------------------------- |
|
|
130
|
-
| `[identifier]` | `admin` | le compte porteur du jeton
|
|
131
|
-
| `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter
|
|
132
|
-
| `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre**
|
|
133
|
-
| `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun
|
|
134
|
-
| `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours**
|
|
135
|
-
| `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents
|
|
136
|
-
| `-j, --json` | — | sortie machine, sans invite
|
|
128
|
+
| Option | Défaut | Ce qu'elle change |
|
|
129
|
+
| ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
130
|
+
| `[identifier]` | `admin` | le compte porteur du jeton |
|
|
131
|
+
| `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
|
|
132
|
+
| `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** porte — son jeton va dans l'en-tête de l'agent, jamais dans `NF_MCP_TOKEN` |
|
|
133
|
+
| `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
|
|
134
|
+
| `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
|
|
135
|
+
| `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents — **refusé** avec un `--resource` qui vise une autre porte |
|
|
136
|
+
| `-j, --json` | — | sortie machine, sans invite |
|
|
137
137
|
|
|
138
138
|
Le plafond de `--ttl` n'est pas décoratif : un jeton posé dans un fichier **est une clé**, et une
|
|
139
139
|
clé se remplace (`security-mcp-token.ts#ttlSeconds`).
|
|
@@ -152,6 +152,13 @@ les agents dont la présence est **constatée** dans le projet — on ne crée p
|
|
|
152
152
|
outil que personne n'utilise ici. La table des cibles vit au cœur
|
|
153
153
|
(`src/nodefony/src/cli/agentTargets.ts#AGENT_TARGETS`) : Claude, Gemini, Vibe, Codex.
|
|
154
154
|
|
|
155
|
+
`NF_MCP_TOKEN` sert **la porte de l'outillage**, et elle seule. Un jeton émis pour une autre porte
|
|
156
|
+
(`--resource`) porte une autre audience : posé dans cette variable, il serait refusé par la porte de
|
|
157
|
+
l'outillage, dont les outils réservés disparaîtraient sans un mot. La commande refuse donc `--write`
|
|
158
|
+
dans ce cas, et n'affiche pas de ligne d'`export` (`security-mcp-token.ts#targetsOtherGate`) : ce
|
|
159
|
+
jeton va dans l'en-tête de l'agent qui parle à cette porte, par exemple
|
|
160
|
+
`claude mcp add --transport http <nom> <porte> --header "Authorization: Bearer <le jeton>"`.
|
|
161
|
+
|
|
155
162
|
Deux garanties qui ne se négocient pas :
|
|
156
163
|
|
|
157
164
|
- **jamais dans un fichier suivi par git** — un jeton commité est un jeton publié, et c'est la seule
|
|
@@ -246,7 +253,7 @@ audiences: ctx.isProd
|
|
|
246
253
|
? []
|
|
247
254
|
: [
|
|
248
255
|
// … les audiences du gabarit
|
|
249
|
-
`${publicBaseUrl(ctx.env)}/
|
|
256
|
+
`${publicBaseUrl(ctx.env)}/mcp`,
|
|
250
257
|
],
|
|
251
258
|
```
|
|
252
259
|
|
package/docs/tokens.md
CHANGED
|
@@ -485,7 +485,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tok
|
|
|
485
485
|
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
|
|
486
486
|
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
|
|
487
487
|
| `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
|
|
488
|
-
| `audiences` | string[] | `[]` | `aud`
|
|
488
|
+
| `audiences` | string[] | `[]` | Liste blanche des `aud` ÉMETTABLES (RFC 8707), la 1ʳᵉ = défaut ; vide = `[issuer]`. Une zone `jwt` n’accepte que SA `resource`, sinon l’audience par défaut (`JwtAuthenticator.ts#JwtAuthenticator.authenticate`) |
|
|
489
489
|
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
490
490
|
| `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts#jwtSchema`) |
|
|
491
491
|
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
|
package/docs/webhooks.md
CHANGED
|
@@ -582,22 +582,30 @@ appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaqu
|
|
|
582
582
|
| IPv4-mapped IPv6 sous toutes ses notations | `::ffff:7f00:1`, `0:0:0:0:0:ffff:127.0.0.1` | rabattu nativement sur les règles IPv4 par `BlockList` (`isBlockedAddress()`, `ssrfGuard.ts#isBlockedAddress`) |
|
|
583
583
|
| IP encodée en dword/hex/octal dans le nom d'hôte | `http://2130706433/` | on passe l'IP **résolue** à `isBlockedAddress()`, jamais la chaîne d'hôte (`ssrfGuard.ts#isBlockedAddress`) |
|
|
584
584
|
| Nom DNS public qui **résout** vers du privé | `hook.evil.com → 10.0.0.5` | toutes les IP résolues sont contrôlées, une seule interne = refus |
|
|
585
|
-
| **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`
|
|
586
|
-
| **Redirection** `302 → 169.254.169.254` | — |
|
|
585
|
+
| **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`fetchPublic.ts#fetchPublic`) |
|
|
586
|
+
| **Redirection** `302 → 169.254.169.254` | — | la livraison ne suit **aucune** redirection (`maxRedirects` à 0) ; le 3xx est rendu tel quel |
|
|
587
|
+
| **Goutte-à-goutte** : un octet toutes les 3 s | — | délai **total** de la livraison, pas seulement d'inactivité (`fetchPublic.ts#IFetchPublicOptions.timeoutMs`) |
|
|
588
|
+
| **Corps de réponse infini** | — | lecture coupée à 2 Kio et connexion détruite (`fetchPublic.ts#IFetchPublicOptions.maxBytes`) |
|
|
587
589
|
|
|
588
590
|
### Les deux subtilités qui font la différence
|
|
589
591
|
|
|
590
592
|
**Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
|
|
591
593
|
publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts#deliverWebhook`)
|
|
592
|
-
installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
|
|
594
|
+
appelle `fetchPublic()` (`fetchPublic.ts#fetchPublic`), qui installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
|
|
593
595
|
d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
|
|
594
596
|
`resolveTarget()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.resolveTarget`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
|
|
595
597
|
**abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
|
|
596
598
|
|
|
597
599
|
**Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
|
|
598
|
-
travail précédent.
|
|
599
|
-
|
|
600
|
-
|
|
600
|
+
travail précédent. `fetchPublic()` n'en suit aucune tant que `maxRedirects` reste à 0 (la livraison ne le change
|
|
601
|
+
jamais), et quand un appelant en autorise, chaque cible est revérifiée comme la première. Couvert par
|
|
602
|
+
`webhookDelivery.test.ts` (« 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi ») et
|
|
603
|
+
`fetchPublic.attack.test.ts`.
|
|
604
|
+
|
|
605
|
+
**La même brique pour tout appel sortant.** `fetchPublic()` est exportée : tout code qui lit une adresse venue d'un
|
|
606
|
+
humain (aperçu de lien, agent, RAG) l'appelle au lieu de `fetch()` — IP vérifiée puis épinglée à chaque saut,
|
|
607
|
+
délai total, corps borné et non décodé. Refus de cible : `SsrfError` (422) ; site muet ou trop lent :
|
|
608
|
+
`FetchPublicError` (`reason`).
|
|
601
609
|
|
|
602
610
|
> [!CAUTION]
|
|
603
611
|
> `assertPublicUrl()` fait un `Fail-closed` sur l'inconnu : une adresse syntaxiquement invalide est
|
|
@@ -1016,7 +1024,7 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
|
|
|
1016
1024
|
| Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
|
|
1017
1025
|
| Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts#WebhookService.rotateSecret`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
|
|
1018
1026
|
| Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
|
|
1019
|
-
| Un `302` vers l'interne n'est pas suivi | **Voulu** :
|
|
1027
|
+
| Un `302` vers l'interne n'est pas suivi | **Voulu** : la livraison ne suit aucune redirection | Rien à corriger — configurer l'URL finale côté destinataire |
|
|
1020
1028
|
| Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
|
|
1021
1029
|
| Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
|
|
1022
1030
|
| Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts#WebhookService.#touchSnapshot`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/security",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.7",
|
|
4
4
|
"description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"private": false,
|
|
37
37
|
"engines": {
|
|
38
|
-
"node": "
|
|
38
|
+
"node": "^24.16.0 || >=26.0.0"
|
|
39
39
|
},
|
|
40
40
|
"keywords": [
|
|
41
41
|
"nodefony",
|
|
@@ -55,28 +55,28 @@
|
|
|
55
55
|
"directory": "src/packages/@nodefony/security"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@simplewebauthn/server": "14.0
|
|
59
|
-
"jose": "6.2.
|
|
58
|
+
"@simplewebauthn/server": "14.1.0",
|
|
59
|
+
"jose": "6.2.17",
|
|
60
60
|
"tslib": "2.9.1",
|
|
61
61
|
"zod": "^4.6.7"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
65
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
66
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
64
|
+
"@nodefony/framework": "^10.0.0-beta.7",
|
|
65
|
+
"@nodefony/http": "^10.0.0-beta.7",
|
|
66
|
+
"@nodefony/user": "^10.0.0-beta.7",
|
|
67
67
|
"@types/node": "26.6.6",
|
|
68
68
|
"@vitest/coverage-v8": "5.1.0",
|
|
69
|
-
"nodefony": "^10.0.0-beta.
|
|
69
|
+
"nodefony": "^10.0.0-beta.7",
|
|
70
70
|
"vitest": "5.1.0"
|
|
71
71
|
},
|
|
72
72
|
"license": "Apache-2.0",
|
|
73
73
|
"readmeFilename": "README.md",
|
|
74
74
|
"contributors": [],
|
|
75
75
|
"peerDependencies": {
|
|
76
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
77
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
78
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
79
|
-
"nodefony": "^10.0.0-beta.
|
|
76
|
+
"@nodefony/framework": "^10.0.0-beta.7",
|
|
77
|
+
"@nodefony/http": "^10.0.0-beta.7",
|
|
78
|
+
"@nodefony/user": "^10.0.0-beta.7",
|
|
79
|
+
"nodefony": "^10.0.0-beta.7"
|
|
80
80
|
},
|
|
81
81
|
"files": [
|
|
82
82
|
"dist",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"publishConfig": {
|
|
87
87
|
"access": "public"
|
|
88
88
|
},
|
|
89
|
-
"homepage": "https://nodefony.
|
|
89
|
+
"homepage": "https://nodefony.net/",
|
|
90
90
|
"bugs": {
|
|
91
91
|
"url": "https://github.com/nodefony/nodefony-core/issues"
|
|
92
92
|
}
|