@nodefony/security 10.0.0-beta.5 → 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.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- 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 +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -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/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -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/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- package/package.json +17 -17
|
@@ -22,7 +22,10 @@ export interface IDeliveryOptions {
|
|
|
22
22
|
readonly allowHttp?: boolean;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* POST le corps signé vers l'URL,
|
|
26
|
-
*
|
|
25
|
+
* POST le corps signé vers l'URL, par {@link fetchPublic} : IP vérifiée épinglée,
|
|
26
|
+
* aucune redirection suivie, délai TOTAL (un destinataire qui goutte un octet
|
|
27
|
+
* ne retient pas la livraison) et lecture du corps coupée à
|
|
28
|
+
* {@link RESPONSE_BODY_CAP} (connexion détruite, pas drainée). Ne lève jamais :
|
|
29
|
+
* tout échec est encodé dans {@link IDeliveryResult}.
|
|
27
30
|
*/
|
|
28
31
|
export declare function deliverWebhook(url: string, body: string, headers: Record<string, string>, opts: IDeliveryOptions): Promise<IDeliveryResult>;
|
|
@@ -4,6 +4,12 @@ import type { FrameAuthorizer, IRealtimeToken } from "../src/realtime/realtimeCo
|
|
|
4
4
|
export interface ITestIdentityOptions {
|
|
5
5
|
/** Identifiant de l'utilisateur (défaut `"alice"`). */
|
|
6
6
|
readonly identifier?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Identifiant IMMUABLE du compte (défaut : `identifier`) — l'`id` que lit la
|
|
9
|
+
* règle `allow` d'un canal, distinct du login comme au runtime. Le poser quand
|
|
10
|
+
* l'appartenance se relit par la clé primaire de l'entité.
|
|
11
|
+
*/
|
|
12
|
+
readonly id?: string;
|
|
7
13
|
/** Rôles portés (défaut `["ROLE_USER"]`) — ceux qu'aurait résolus le pare-feu. */
|
|
8
14
|
readonly roles?: readonly string[];
|
|
9
15
|
/** Scopes portés (défaut aucun) — l'axe des jetons d'API, pas de la session. */
|
package/docs/api-keys.md
CHANGED
|
@@ -279,7 +279,9 @@ jamais par le préfixe.
|
|
|
279
279
|
|
|
280
280
|
### Émission — `ApiKeyService.createForSubject()`
|
|
281
281
|
|
|
282
|
-
`ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission.
|
|
282
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Un `tenantId` rempli est refusé d'entrée (`ApiKeyError` 400) : le
|
|
283
|
+
multi-tenant n'existe pas, aucun lecteur ne cloisonne sur ce champ, et une clé « rattachée à un
|
|
284
|
+
locataire » verrait tout. Il sera accepté le jour où il sera appliqué. Ensuite, dans l'ordre :
|
|
283
285
|
|
|
284
286
|
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
285
287
|
`apiKeys.ts#ApiKeyService.#normalizeName`).
|
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/authenticators.md
CHANGED
|
@@ -270,8 +270,9 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
|
270
270
|
partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts#resolveSessionIdentity`).
|
|
271
271
|
- `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
|
|
272
272
|
principal courant (`SessionAuthenticator.ts#SessionAuthenticator.onSuccess`).
|
|
273
|
-
- **Pas de `challenge()`** : session absente = 401
|
|
274
|
-
jamais une popup
|
|
273
|
+
- **Pas de `challenge()`** : session absente = 401 portant le défi de repli du pare-feu,
|
|
274
|
+
`Session realm="nodefony"` (RFC 9110 §15.5.2) — schéma inconnu des navigateurs, jamais une popup ;
|
|
275
|
+
le front redirige vers son écran de login (`SessionAuthenticator.ts#SessionAuthenticator`).
|
|
275
276
|
|
|
276
277
|
### `jwt` — Bearer signé pour les API (RFC 6750 + BCP RFC 8725)
|
|
277
278
|
|
|
@@ -348,8 +349,9 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
348
349
|
ouverte (logout, changement de compte, `jti` denylisté). En mode session,
|
|
349
350
|
`buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
|
|
350
351
|
lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts#SessionLike.storage`). En mode jeton porteur, la
|
|
351
|
-
preuve est autre : `
|
|
352
|
-
|
|
352
|
+
preuve est autre : `jti` denylisté, `invalidBefore`, puis `exp` — dans cet ordre, car révoqué
|
|
353
|
+
l'emporte sur expiré — et la cause est rendue : un jeton expiré ferme la socket en `4006`, un jeton
|
|
354
|
+
révoqué en `4001` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.#buildBearerStatus`).
|
|
353
355
|
|
|
354
356
|
> [!NOTE]
|
|
355
357
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
package/docs/csrf.md
CHANGED
|
@@ -271,8 +271,9 @@ sûres → coût nul sur le GET dominant (`csrf.ts#Csrf.enforce`). Pour une muta
|
|
|
271
271
|
Lectures durcies côté firewall :
|
|
272
272
|
|
|
273
273
|
- en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
|
|
274
|
-
`headerValue()`, `firewall.ts#headerValue`) ; cookie
|
|
275
|
-
|
|
274
|
+
`headerValue()`, `firewall.ts#headerValue`) ; cookie `csrf-token` lu sur le contexte, dont
|
|
275
|
+
l'en-tête `Cookie` n'a qu'un lecteur (`requestCookie()`, `firewall.ts#requestCookie`) — parsé à la
|
|
276
|
+
construction du contexte, donc avant toute étape du pare-feu ;
|
|
276
277
|
- hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
|
|
277
278
|
(`firewall.ts#Firewall`) ;
|
|
278
279
|
- le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
|
package/docs/external-jwt.md
CHANGED
|
@@ -79,7 +79,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
|
|
|
79
79
|
| `issuers[].jwksUri` | découvert | Déclaré, aucune découverte n'a lieu — utile pour un émetteur sans métadonnées, ou un démarrage à froid sans requête sortante. |
|
|
80
80
|
| `issuers[].algorithms` | `RS256`, `ES256`, `EdDSA` | Allowlist **serveur** : l'algorithme n'est jamais déduit de l'en-tête du jeton (RFC 8725 §3.1). À restreindre à ce que l'émetteur utilise réellement. |
|
|
81
81
|
| `issuers[].typ` | non exigé | `at+jwt` pour un émetteur conforme RFC 9068. |
|
|
82
|
-
| `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss` et `
|
|
82
|
+
| `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss`, `aud`, `exp` et `iat` (toujours exigés, RFC 9068 §2.2 ; un `iat` daté du futur est refusé). |
|
|
83
83
|
| `issuers[].subjectMapping` | `prefixed` | Comment le `sub` devient un identifiant local — **voir l'encadré ci-dessous**. |
|
|
84
84
|
| `subjectPolicy` | `require` | `require` : un compte local est exigé. `ephemeral` : l'appelant vit le temps de la requête. |
|
|
85
85
|
| `ephemeralRoles` | `[]` | Rôles accordés en mode `ephemeral`. Vide à dessein. |
|
|
@@ -98,6 +98,10 @@ rattaché au compte local du même nom.
|
|
|
98
98
|
`subject` ne se déclare donc que si l'on maîtrise l'espace de noms de cet émetteur : typiquement
|
|
99
99
|
parce qu'il **est** cette application, ou parce que ses sujets sont déjà des identifiants locaux.
|
|
100
100
|
|
|
101
|
+
`subjectMapping` ne règle que la **recherche d'un compte local** : sous `subjectPolicy: "ephemeral"`,
|
|
102
|
+
où aucun compte n'est cherché, l'identifiant de l'appelant est **toujours** `<issuer>#<sub>`, quel
|
|
103
|
+
que soit le réglage (`ExternalSubjectResolver.ts#ExternalSubjectResolver.resolve`).
|
|
104
|
+
|
|
101
105
|
### `subjectPolicy` — qui a le droit d'exister
|
|
102
106
|
|
|
103
107
|
- **`require`** (défaut) — le sujet doit correspondre à un compte local (`loadUserByIdentifier`).
|
package/docs/firewall.md
CHANGED
|
@@ -243,8 +243,9 @@ Credential = l'**identifiant** posé dans le blob de session (jamais un secret).
|
|
|
243
243
|
régénère l'ID (anti-fixation).
|
|
244
244
|
- **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts#SessionAuthenticator.authenticate`) → rôles frais,
|
|
245
245
|
révocation et verrouillage effectifs immédiatement.
|
|
246
|
-
- **Pas de `challenge()`** : session absente = 401
|
|
247
|
-
(pas de
|
|
246
|
+
- **Pas de `challenge()`** : session absente = 401 portant le défi de repli du pare-feu,
|
|
247
|
+
`Session realm="nodefony"` (RFC 9110 §15.5.2) — schéma inconnu des navigateurs, donc pas de
|
|
248
|
+
popup ; le front redirige vers son écran de login.
|
|
248
249
|
|
|
249
250
|
### `userpassword` — HTTP Basic, avec throttle NIST
|
|
250
251
|
|