@zarex/express 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kodjogbe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # @zarex/express
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Middleware Zarex pour Express — protège votre application contre les bots, les abus de trafic, les injections (SQL, XSS, SSRF...) et les fuites de données sensibles, en quelques lignes.
4
+
5
+ Compatible Express 4 et 5 (aucune dépendance à `express` à l'exécution).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @zarex/express
11
+ ```
12
+
13
+ ## Démarrage rapide
14
+
15
+ ```ts
16
+ import express from "express";
17
+ import { zarex, shield, rateLimit } from "@zarex/express";
18
+
19
+ const app = express();
20
+
21
+ app.use(
22
+ zarex({
23
+ key: process.env.ZAREX_KEY!,
24
+ rules: [shield(), rateLimit({ max: 100, window: "1m" })],
25
+ })
26
+ );
27
+
28
+ app.get("/", (req, res) => res.send("ok"));
29
+ app.listen(3000);
30
+ ```
31
+
32
+ Une requête refusée reçoit automatiquement `403 Forbidden` (ou `429 Too Many Requests` avec l'en-tête `Retry-After` pour une limite de débit) — sans aucun code supplémentaire.
33
+
34
+ **Fail-open** : si Zarex est injoignable, lent ou répond de façon inattendue, la requête passe toujours. Une panne chez Zarex ne doit jamais faire tomber votre application.
35
+
36
+ ## L'adresse IP du visiteur
37
+
38
+ Par défaut (`ipSource` non renseigné), l'adaptateur utilise `req.ip`, calculé par Express lui-même selon le réglage [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) de votre application :
39
+
40
+ ```ts
41
+ app.set("trust proxy", 1); // 1 proxy de confiance devant Express (ex. un load balancer)
42
+ ```
43
+
44
+ Configurez `trust proxy` pour correspondre à **votre** infrastructure réelle — un mauvais réglage ici (ou son absence derrière un vrai proxy) fait passer tous vos visiteurs pour une seule et même adresse IP, ce qui casse silencieusement toute règle de limite de débit basée sur l'IP.
45
+
46
+ ## Options
47
+
48
+ ```ts
49
+ zarex({
50
+ key: string, // obligatoire : clé API du projet
51
+ rules: Rule[], // obligatoire : 1 à 10 règles, voir ci-dessous
52
+ ipSource?: IpSource, // défaut "express" (voir ci-dessus)
53
+ inspectBody?: boolean, // défaut false — voir "Inspecter le corps"
54
+ secureHeaders?: boolean | SecureHeadersOptions,
55
+ responseGuard?: boolean | ResponseGuardOptions,
56
+ onDeny?: (req, res, decision) => void,
57
+ onDegraded?: (req, decision) => void,
58
+ call?: (req) => ProtectCall | undefined,
59
+ timeoutMs?: number, // défaut 500 ms
60
+ breaker?: BreakerOptions | false,
61
+ })
62
+ ```
63
+
64
+ ### Inspecter le corps de la requête (`inspectBody`)
65
+
66
+ ```ts
67
+ app.use(express.json()); // AVANT le middleware Zarex
68
+ app.use(zarex({ ..., inspectBody: true }));
69
+ ```
70
+
71
+ Par défaut, le corps n'est **jamais** transmis à Zarex (il peut contenir des mots de passe). Avec `inspectBody: true`, le middleware Zarex doit être placé **après** `express.json()` / `express.urlencoded()` pour que `req.body` soit déjà peuplé. Le SDK ne lit jamais le flux lui-même.
72
+
73
+ ### En-têtes de sécurité (`secureHeaders`)
74
+
75
+ ```ts
76
+ zarex({ ..., secureHeaders: true }); // HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy...
77
+ ```
78
+
79
+ Ajoutés à **toutes** les réponses, y compris les refus. `true` applique des défauts prudents ; passez un objet pour personnaliser (voir `SecureHeadersOptions` dans `@zarex/core`). Retire aussi l'en-tête `X-Powered-By`.
80
+
81
+ ### Surveillance des réponses (`responseGuard`)
82
+
83
+ ```ts
84
+ zarex({ ..., responseGuard: true });
85
+ ```
86
+
87
+ Surveille les réponses de **votre** application (`res.send` / `res.json`) et masque (ou bloque) les cartes bancaires, clés d'API, jetons JWT, mots de passe... qui s'y retrouveraient par erreur. Tout se passe sur votre serveur, rien n'est jamais envoyé à Zarex pour cette vérification.
88
+
89
+ **Limite** : ne couvre pas `res.write()` / `res.end()` appelés directement, ni les flux (streams).
90
+
91
+ ### Identifiants personnalisés (`call`)
92
+
93
+ ```ts
94
+ app.use(authMiddleware); // avant Zarex, pour que req.user existe
95
+ app.use(
96
+ zarex({
97
+ key,
98
+ rules: [rateLimit({ max: 100, window: "1m", characteristics: ["userId"] })],
99
+ call: (req) => ({ characteristics: { userId: req.user?.id } }),
100
+ })
101
+ );
102
+ ```
103
+
104
+ Place le middleware Zarex **après** votre authentification pour que l'identifiant soit disponible. Les valeurs sont hachées en SHA-256 avant l'envoi à Zarex. Une erreur dans cette fonction est silencieusement ignorée (la requête continue, sans caractéristique personnalisée).
105
+
106
+ `call` peut aussi fournir `tokensRequested` (règle `tokenBucket`), `email`/`phone` (validation) ou `text` (règles `sensitiveInfo`/`promptInjection`).
107
+
108
+ ### Lire le résultat de la décision
109
+
110
+ ```ts
111
+ app.get("/", (req, res) => {
112
+ console.log(req.zarex?.verdict); // "ALLOW" | "DENY" | "ERROR"
113
+ res.send("ok");
114
+ });
115
+ ```
116
+
117
+ Disponible pour toute requête ayant traversé le middleware, qu'elle ait été autorisée ou non.
118
+
119
+ ## Règles disponibles
120
+
121
+ | Fonction | Résumé |
122
+ |---|---|
123
+ | `shield(opts?)` | Pare-feu applicatif : injection SQL, XSS, traversée de répertoires, SSRF, SSTI... |
124
+ | `rateLimit({ max, window, ... })` | Limite de requêtes par visiteur sur une fenêtre de temps (`"30s"`, `"1m"`, `"1h"`, `"1d"`). |
125
+ | `tokenBucket({ capacity, refillRate, interval })` | Budget de jetons avec pics autorisés (ex. appels à un modèle d'IA). |
126
+ | `detectBot(opts?)` | Détecte les robots connus, avec vérification DNS inversée. |
127
+ | `requestFilter({ expressions, logic })` | Règles personnalisées sur IP, chemin, en-têtes, cookies, pays... |
128
+ | `sensitiveInfo(opts?)` | Refuse une requête contenant une donnée sensible (nécessite `inspectBody`). |
129
+ | `emailValidation(opts?)` | Valide un email donné via `call: () => ({ email })`. |
130
+ | `phoneValidation(opts?)` | Valide un numéro donné via `call`, orienté opérateurs africains (MTN, Moov, Orange, Wave...). |
131
+ | `promptInjection(opts?)` | Détecte les tentatives de manipulation d'un assistant IA, via `call: () => ({ text })`. |
132
+
133
+ Chaque règle accepte `mode: "LIVE" | "DRY_RUN" | "OFF"` — `"DRY_RUN"` évalue et journalise sans jamais bloquer, pour observer une règle avant de l'activer. Détails complets des options de chaque règle : voir [`@zarex/core`](https://www.npmjs.com/package/@zarex/core).
134
+
135
+ ## Réponse de refus personnalisée (`onDeny`)
136
+
137
+ ```ts
138
+ zarex({
139
+ ...,
140
+ onDeny: (req, res, decision) => {
141
+ res.status(403).json({ message: "Accès refusé", id: decision.id });
142
+ },
143
+ });
144
+ ```
145
+
146
+ Sans `onDeny`, la réponse par défaut est `403` (ou `429` + `Retry-After` pour une limite de débit), au format `{ error: { code, message, request_id } }`.
147
+
148
+ ## Licence
149
+
150
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CLOUDFLARE_RANGES: () => import_core2.CLOUDFLARE_RANGES,
24
+ PRIVATE_NETWORKS: () => import_core2.PRIVATE_NETWORKS,
25
+ createZarex: () => import_core2.createZarex,
26
+ detectBot: () => import_core2.detectBot,
27
+ emailValidation: () => import_core2.emailValidation,
28
+ phoneValidation: () => import_core2.phoneValidation,
29
+ promptInjection: () => import_core2.promptInjection,
30
+ rateLimit: () => import_core2.rateLimit,
31
+ requestFilter: () => import_core2.requestFilter,
32
+ secureHeaders: () => import_core2.secureHeaders,
33
+ sensitiveInfo: () => import_core2.sensitiveInfo,
34
+ shield: () => import_core2.shield,
35
+ tokenBucket: () => import_core2.tokenBucket,
36
+ withSecureHeaders: () => import_core2.withSecureHeaders,
37
+ zarex: () => zarex
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+ var import_core = require("@zarex/core");
41
+ var import_core2 = require("@zarex/core");
42
+ var decoder = new TextDecoder("utf-8");
43
+ function installResponseGuard(res, guard) {
44
+ const original = res.send;
45
+ if (typeof original !== "function") return;
46
+ res.send = function guardedSend(body) {
47
+ let next = body;
48
+ try {
49
+ const ct = String((typeof res.get === "function" ? res.get("Content-Type") : void 0) ?? "");
50
+ const isString = typeof body === "string";
51
+ const isBytes = body instanceof Uint8Array;
52
+ if (isString && (ct === "" || (0, import_core.isScannableContentType)(ct)) || isBytes && (0, import_core.isScannableContentType)(ct)) {
53
+ const text = isString ? body : decoder.decode(body);
54
+ const r = (0, import_core.applyGuard)(text, ct || "text/html", guard);
55
+ if (r.action === "masked") next = r.body;
56
+ else if (r.action === "blocked" && !res.headersSent) {
57
+ res.status(500);
58
+ res.set("Content-Type", "application/json; charset=utf-8");
59
+ next = r.body;
60
+ }
61
+ }
62
+ } catch {
63
+ next = body;
64
+ }
65
+ return original.call(this, next);
66
+ };
67
+ }
68
+ function bodyToString(body) {
69
+ if (body === void 0 || body === null) return void 0;
70
+ if (typeof body === "string") return body;
71
+ if (typeof body === "object" && typeof body.toString === "function" && body.constructor?.name === "Buffer") {
72
+ return body.toString("utf8");
73
+ }
74
+ try {
75
+ const s = JSON.stringify(body);
76
+ return typeof s === "string" ? s : void 0;
77
+ } catch {
78
+ return void 0;
79
+ }
80
+ }
81
+ function defaultDeny(_req, res, d) {
82
+ const limited = d.deniedBy?.ruleType === "RATE_LIMIT" || d.deniedBy?.ruleType === "TOKEN_BUCKET";
83
+ if (res.headersSent) return;
84
+ if (limited) {
85
+ if (d.retryAfterSeconds !== void 0) res.set("Retry-After", String(d.retryAfterSeconds));
86
+ res.status(429).json({ error: { code: "rate_limited", message: "Too many requests", request_id: d.id } });
87
+ } else {
88
+ res.status(403).json({ error: { code: "forbidden", message: "Request blocked", request_id: d.id } });
89
+ }
90
+ }
91
+ function zarex(options, extra = {}) {
92
+ const isInstance = typeof options.protect === "function";
93
+ const opts = isInstance ? extra : options;
94
+ const instance = isInstance ? options : (0, import_core.createZarex)({ ...options, ipSource: options.ipSource ?? "express" });
95
+ const prepared = (0, import_core.prepareSecureHeaders)(opts.secureHeaders);
96
+ const guard = (0, import_core.normalizeGuardOptions)(opts.responseGuard);
97
+ return async function zarexMiddleware(req, res, next) {
98
+ let decision;
99
+ try {
100
+ if (prepared) {
101
+ const set = (0, import_core.isLocalHost)(req.hostname ?? (typeof req.headers["host"] === "string" ? req.headers["host"] : void 0)) ? prepared.http : prepared.https;
102
+ for (const [k, v] of Object.entries(set)) res.set(k, v);
103
+ if (prepared.removePoweredBy) res.removeHeader?.("X-Powered-By");
104
+ }
105
+ if (guard) installResponseGuard(res, guard);
106
+ } catch {
107
+ }
108
+ try {
109
+ let trustProxy;
110
+ try {
111
+ const v = req.app?.get?.("trust proxy");
112
+ trustProxy = v === void 0 ? void 0 : Boolean(v) && v !== "false";
113
+ } catch {
114
+ trustProxy = void 0;
115
+ }
116
+ const host = typeof req.headers["host"] === "string" ? req.headers["host"] : req.hostname;
117
+ const body = opts.inspectBody ? bodyToString(req.body) : void 0;
118
+ let call;
119
+ try {
120
+ call = opts.call?.(req);
121
+ } catch {
122
+ call = void 0;
123
+ }
124
+ decision = await instance.protect({
125
+ method: req.method,
126
+ path: req.originalUrl ?? req.url ?? "/",
127
+ host,
128
+ headers: req.headers,
129
+ socketIp: req.socket?.remoteAddress,
130
+ expressIp: req.ip,
131
+ expressTrustProxy: trustProxy,
132
+ body
133
+ }, call);
134
+ req.zarex = decision;
135
+ } catch {
136
+ next();
137
+ return;
138
+ }
139
+ if (decision.denied) {
140
+ try {
141
+ (opts.onDeny ?? defaultDeny)(req, res, decision);
142
+ } catch (err) {
143
+ next(err);
144
+ }
145
+ return;
146
+ }
147
+ if (decision.degraded && opts.onDegraded) {
148
+ try {
149
+ opts.onDegraded(req, decision);
150
+ } catch {
151
+ }
152
+ }
153
+ next();
154
+ };
155
+ }
156
+ // Annotate the CommonJS export names for ESM import in node:
157
+ 0 && (module.exports = {
158
+ CLOUDFLARE_RANGES,
159
+ PRIVATE_NETWORKS,
160
+ createZarex,
161
+ detectBot,
162
+ emailValidation,
163
+ phoneValidation,
164
+ promptInjection,
165
+ rateLimit,
166
+ requestFilter,
167
+ secureHeaders,
168
+ sensitiveInfo,
169
+ shield,
170
+ tokenBucket,
171
+ withSecureHeaders,
172
+ zarex
173
+ });
174
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import {\r\n applyGuard,\r\n createZarex,\r\n isLocalHost,\r\n isScannableContentType,\r\n normalizeGuardOptions,\r\n prepareSecureHeaders,\r\n type Decision,\r\n type ProtectCall,\r\n type ResponseGuardOptions,\r\n type SecureHeadersOptions,\r\n type Zarex,\r\n type ZarexOptions,\r\n} from \"@zarex/core\";\r\n\r\n/**\r\n * Types minimaux d'Express : on ne dépend pas de `express` à l'exécution, donc\r\n * le middleware fonctionne avec Express 4 et 5 (et tout framework compatible).\r\n */\r\nexport interface ExpressLikeRequest {\r\n method: string;\r\n originalUrl?: string;\r\n url?: string;\r\n ip?: string | undefined;\r\n headers: Record<string, string | string[] | undefined>;\r\n socket?: { remoteAddress?: string | undefined } | undefined;\r\n hostname?: string;\r\n body?: unknown;\r\n secure?: boolean;\r\n app?: { get?(setting: string): unknown };\r\n zarex?: Decision;\r\n}\r\nexport interface ExpressLikeResponse {\r\n status(code: number): this;\r\n set(name: string, value: string): this;\r\n json(body: unknown): unknown;\r\n send?(body?: unknown): unknown;\r\n get?(name: string): string | undefined;\r\n getHeader?(name: string): unknown;\r\n removeHeader?(name: string): void;\r\n headersSent?: boolean;\r\n}\r\nexport type NextFn = (err?: unknown) => void;\r\n\r\nexport interface ExpressZarexOptions extends Omit<ZarexOptions, \"ipSource\"> {\r\n /**\r\n * Défaut : \"express\" — l'adresse est celle qu'Express calcule (`req.ip`), selon\r\n * `app.set(\"trust proxy\", …)`. C'est la méthode la plus sûre avec Express :\r\n * configurez « trust proxy » pour VOTRE infrastructure (voir la doc Express).\r\n */\r\n ipSource?: ZarexOptions[\"ipSource\"];\r\n /**\r\n * Transmettre le corps de la requête au Shield (défaut false : il peut contenir\r\n * des mots de passe). Nécessite que le middleware soit placé APRÈS express.json() /\r\n * express.urlencoded(). Le SDK ne lit jamais le flux lui-même.\r\n */\r\n inspectBody?: boolean;\r\n /**\r\n * En-têtes de sécurité (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy…), ajoutés à TOUTES les réponses,\r\n * y compris les refus. `true` = défauts prudents ; sinon voir SecureHeadersOptions. Retire aussi X-Powered-By.\r\n */\r\n secureHeaders?: SecureHeadersOptions | boolean;\r\n /**\r\n * Surveille les réponses de votre application (res.send / res.json) : masque (ou bloque) les cartes bancaires,\r\n * clés d'API, jetons JWT, mots de passe… qui s'y trouveraient par erreur. Tout se passe sur votre serveur.\r\n * LIMITE : ne couvre ni res.write()/res.end() directs, ni les flux (streams).\r\n */\r\n responseGuard?: ResponseGuardOptions | boolean;\r\n /** Réponse personnalisée en cas de refus. Si absente : 429 (limite de débit) ou 403. */\r\n onDeny?: (req: ExpressLikeRequest, res: ExpressLikeResponse, decision: Decision) => void;\r\n /** Appelé quand le moteur n'a pas pu décider (la requête passe). */\r\n onDegraded?: (req: ExpressLikeRequest, decision: Decision) => void;\r\n /**\r\n * Identifiants / jetons propres à chaque requête, ex. `(req) => ({ characteristics: { userId: req.user?.id } })`.\r\n * Placez alors le middleware APRÈS votre authentification. Une erreur dans cette fonction est ignorée.\r\n */\r\n call?: (req: ExpressLikeRequest) => ProtectCall | undefined;\r\n}\r\n\r\nconst decoder = new TextDecoder(\"utf-8\");\r\n\r\n/** Enveloppe res.send : les corps TEXTE (string / octets texte) passent par la surveillance. Ne lève jamais. */\r\nfunction installResponseGuard(res: ExpressLikeResponse, guard: ResponseGuardOptions): void {\r\n const original = res.send;\r\n if (typeof original !== \"function\") return;\r\n res.send = function guardedSend(this: ExpressLikeResponse, body?: unknown): unknown {\r\n let next: unknown = body;\r\n try {\r\n const ct = String((typeof res.get === \"function\" ? res.get(\"Content-Type\") : undefined) ?? \"\");\r\n const isString = typeof body === \"string\";\r\n const isBytes = body instanceof Uint8Array;\r\n // Une chaîne sans type déclaré est du texte (Express la servira en text/html).\r\n if ((isString && (ct === \"\" || isScannableContentType(ct))) || (isBytes && isScannableContentType(ct))) {\r\n const text = isString ? (body as string) : decoder.decode(body as Uint8Array);\r\n const r = applyGuard(text, ct || \"text/html\", guard);\r\n if (r.action === \"masked\") next = r.body;\r\n else if (r.action === \"blocked\" && !res.headersSent) {\r\n res.status(500);\r\n res.set(\"Content-Type\", \"application/json; charset=utf-8\");\r\n next = r.body;\r\n }\r\n }\r\n } catch {\r\n next = body; // jamais casser la réponse de l'application\r\n }\r\n return (original as (b?: unknown) => unknown).call(this, next);\r\n };\r\n}\r\n\r\nfunction bodyToString(body: unknown): string | undefined {\r\n if (body === undefined || body === null) return undefined;\r\n if (typeof body === \"string\") return body;\r\n if (typeof body === \"object\" && typeof (body as { toString?: unknown }).toString === \"function\" && (body as { constructor?: { name?: string } }).constructor?.name === \"Buffer\") {\r\n return (body as { toString(enc: string): string }).toString(\"utf8\");\r\n }\r\n try {\r\n const s = JSON.stringify(body);\r\n return typeof s === \"string\" ? s : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Réponse de refus par défaut : générique, sans détail interne. */\r\nfunction defaultDeny(_req: ExpressLikeRequest, res: ExpressLikeResponse, d: Decision): void {\r\n const limited = d.deniedBy?.ruleType === \"RATE_LIMIT\" || d.deniedBy?.ruleType === \"TOKEN_BUCKET\";\r\n if (res.headersSent) return;\r\n if (limited) {\r\n if (d.retryAfterSeconds !== undefined) res.set(\"Retry-After\", String(d.retryAfterSeconds));\r\n res.status(429).json({ error: { code: \"rate_limited\", message: \"Too many requests\", request_id: d.id } });\r\n } else {\r\n res.status(403).json({ error: { code: \"forbidden\", message: \"Request blocked\", request_id: d.id } });\r\n }\r\n}\r\n\r\n/**\r\n * Middleware Express.\r\n *\r\n * app.use(zarex({ key: process.env.ZAREX_KEY!, baseUrl: process.env.ZAREX_URL!, rules: [shield(), rateLimit({ max: 100, window: \"1m\" })] }));\r\n *\r\n * Fail-open : si Zarex est injoignable, lent ou répond mal, la requête passe.\r\n * Le résultat est disponible dans `req.zarex`.\r\n */\r\nexport function zarex(options: ExpressZarexOptions | Zarex, extra: Pick<ExpressZarexOptions, \"inspectBody\" | \"onDeny\" | \"onDegraded\" | \"call\" | \"secureHeaders\" | \"responseGuard\"> = {}) {\r\n const isInstance = typeof (options as Zarex).protect === \"function\";\r\n const opts = (isInstance ? extra : options) as ExpressZarexOptions;\r\n const instance: Zarex = isInstance\r\n ? (options as Zarex)\r\n : createZarex({ ...(options as ExpressZarexOptions), ipSource: (options as ExpressZarexOptions).ipSource ?? \"express\" });\r\n\r\n // Préparés UNE fois : une option invalide est signalée au démarrage, pas à la première requête.\r\n const prepared = prepareSecureHeaders(opts.secureHeaders);\r\n const guard = normalizeGuardOptions(opts.responseGuard);\r\n\r\n return async function zarexMiddleware(req: ExpressLikeRequest, res: ExpressLikeResponse, next: NextFn): Promise<void> {\r\n let decision: Decision | undefined;\r\n try {\r\n if (prepared) {\r\n const set = isLocalHost(req.hostname ?? (typeof req.headers[\"host\"] === \"string\" ? (req.headers[\"host\"] as string) : undefined)) ? prepared.http : prepared.https;\r\n for (const [k, v] of Object.entries(set)) res.set(k, v);\r\n if (prepared.removePoweredBy) res.removeHeader?.(\"X-Powered-By\");\r\n }\r\n if (guard) installResponseGuard(res, guard);\r\n } catch {\r\n /* les protections de réponse ne doivent jamais casser la requête */\r\n }\r\n try {\r\n let trustProxy: boolean | undefined;\r\n try {\r\n const v = req.app?.get?.(\"trust proxy\");\r\n trustProxy = v === undefined ? undefined : Boolean(v) && v !== \"false\";\r\n } catch {\r\n trustProxy = undefined;\r\n }\r\n const host = typeof req.headers[\"host\"] === \"string\" ? (req.headers[\"host\"] as string) : req.hostname;\r\n const body = opts.inspectBody ? bodyToString(req.body) : undefined;\r\n let call: ProtectCall | undefined;\r\n try {\r\n call = opts.call?.(req);\r\n } catch {\r\n call = undefined;\r\n }\r\n decision = await instance.protect({\r\n method: req.method,\r\n path: req.originalUrl ?? req.url ?? \"/\",\r\n host,\r\n headers: req.headers,\r\n socketIp: req.socket?.remoteAddress,\r\n expressIp: req.ip,\r\n expressTrustProxy: trustProxy,\r\n body,\r\n }, call);\r\n req.zarex = decision;\r\n } catch {\r\n // protect() ne lève pas, mais quoi qu'il arrive : la requête du client passe.\r\n next();\r\n return;\r\n }\r\n\r\n if (decision.denied) {\r\n try {\r\n (opts.onDeny ?? defaultDeny)(req, res, decision);\r\n } catch (err) {\r\n // Toujours propager par next(err), même si des en-têtes ont déjà été écrits : Express gère ce\r\n // cas lui-même (son gestionnaire d'erreur par défaut referme la connexion sans tenter d'écrire\r\n // une 2ᵉ réponse). Le garde-fou précédent (`if (!res.headersSent)`) empêchait tout appel à\r\n // next() dans ce cas précis : un onDeny qui écrivait partiellement la réponse puis levait\r\n // laissait la requête bloquée indéfiniment, sans aucune trace (audit du 04/11/2026, voir\r\n // AUDIT_SECURITE_COMPLET.md).\r\n next(err);\r\n }\r\n return;\r\n }\r\n if (decision.degraded && opts.onDegraded) {\r\n try {\r\n opts.onDegraded(req, decision);\r\n } catch {\r\n /* le rappel du développeur ne doit pas casser la requête */\r\n }\r\n }\r\n next();\r\n };\r\n}\r\n\r\nexport {\r\n shield,\r\n rateLimit,\r\n tokenBucket,\r\n detectBot,\r\n requestFilter,\r\n sensitiveInfo,\r\n emailValidation,\r\n phoneValidation,\r\n promptInjection,\r\n secureHeaders,\r\n withSecureHeaders,\r\n createZarex,\r\n PRIVATE_NETWORKS,\r\n CLOUDFLARE_RANGES,\r\n} from \"@zarex/core\";\r\nexport type { SecureHeadersOptions, ResponseGuardOptions, ResponseGuardEvent } from \"@zarex/core\";\r\nexport type { Decision, Zarex, ZarexOptions, IpSource, Rule } from \"@zarex/core\";"],"mappings":"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","names":["import_core"]}
@@ -0,0 +1,80 @@
1
+ import { Decision, ZarexOptions, SecureHeadersOptions, ResponseGuardOptions, ProtectCall, Zarex } from '@zarex/core';
2
+ export { CLOUDFLARE_RANGES, Decision, IpSource, PRIVATE_NETWORKS, ResponseGuardEvent, ResponseGuardOptions, Rule, SecureHeadersOptions, Zarex, ZarexOptions, createZarex, detectBot, emailValidation, phoneValidation, promptInjection, rateLimit, requestFilter, secureHeaders, sensitiveInfo, shield, tokenBucket, withSecureHeaders } from '@zarex/core';
3
+
4
+ /**
5
+ * Types minimaux d'Express : on ne dépend pas de `express` à l'exécution, donc
6
+ * le middleware fonctionne avec Express 4 et 5 (et tout framework compatible).
7
+ */
8
+ interface ExpressLikeRequest {
9
+ method: string;
10
+ originalUrl?: string;
11
+ url?: string;
12
+ ip?: string | undefined;
13
+ headers: Record<string, string | string[] | undefined>;
14
+ socket?: {
15
+ remoteAddress?: string | undefined;
16
+ } | undefined;
17
+ hostname?: string;
18
+ body?: unknown;
19
+ secure?: boolean;
20
+ app?: {
21
+ get?(setting: string): unknown;
22
+ };
23
+ zarex?: Decision;
24
+ }
25
+ interface ExpressLikeResponse {
26
+ status(code: number): this;
27
+ set(name: string, value: string): this;
28
+ json(body: unknown): unknown;
29
+ send?(body?: unknown): unknown;
30
+ get?(name: string): string | undefined;
31
+ getHeader?(name: string): unknown;
32
+ removeHeader?(name: string): void;
33
+ headersSent?: boolean;
34
+ }
35
+ type NextFn = (err?: unknown) => void;
36
+ interface ExpressZarexOptions extends Omit<ZarexOptions, "ipSource"> {
37
+ /**
38
+ * Défaut : "express" — l'adresse est celle qu'Express calcule (`req.ip`), selon
39
+ * `app.set("trust proxy", …)`. C'est la méthode la plus sûre avec Express :
40
+ * configurez « trust proxy » pour VOTRE infrastructure (voir la doc Express).
41
+ */
42
+ ipSource?: ZarexOptions["ipSource"];
43
+ /**
44
+ * Transmettre le corps de la requête au Shield (défaut false : il peut contenir
45
+ * des mots de passe). Nécessite que le middleware soit placé APRÈS express.json() /
46
+ * express.urlencoded(). Le SDK ne lit jamais le flux lui-même.
47
+ */
48
+ inspectBody?: boolean;
49
+ /**
50
+ * En-têtes de sécurité (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy…), ajoutés à TOUTES les réponses,
51
+ * y compris les refus. `true` = défauts prudents ; sinon voir SecureHeadersOptions. Retire aussi X-Powered-By.
52
+ */
53
+ secureHeaders?: SecureHeadersOptions | boolean;
54
+ /**
55
+ * Surveille les réponses de votre application (res.send / res.json) : masque (ou bloque) les cartes bancaires,
56
+ * clés d'API, jetons JWT, mots de passe… qui s'y trouveraient par erreur. Tout se passe sur votre serveur.
57
+ * LIMITE : ne couvre ni res.write()/res.end() directs, ni les flux (streams).
58
+ */
59
+ responseGuard?: ResponseGuardOptions | boolean;
60
+ /** Réponse personnalisée en cas de refus. Si absente : 429 (limite de débit) ou 403. */
61
+ onDeny?: (req: ExpressLikeRequest, res: ExpressLikeResponse, decision: Decision) => void;
62
+ /** Appelé quand le moteur n'a pas pu décider (la requête passe). */
63
+ onDegraded?: (req: ExpressLikeRequest, decision: Decision) => void;
64
+ /**
65
+ * Identifiants / jetons propres à chaque requête, ex. `(req) => ({ characteristics: { userId: req.user?.id } })`.
66
+ * Placez alors le middleware APRÈS votre authentification. Une erreur dans cette fonction est ignorée.
67
+ */
68
+ call?: (req: ExpressLikeRequest) => ProtectCall | undefined;
69
+ }
70
+ /**
71
+ * Middleware Express.
72
+ *
73
+ * app.use(zarex({ key: process.env.ZAREX_KEY!, baseUrl: process.env.ZAREX_URL!, rules: [shield(), rateLimit({ max: 100, window: "1m" })] }));
74
+ *
75
+ * Fail-open : si Zarex est injoignable, lent ou répond mal, la requête passe.
76
+ * Le résultat est disponible dans `req.zarex`.
77
+ */
78
+ declare function zarex(options: ExpressZarexOptions | Zarex, extra?: Pick<ExpressZarexOptions, "inspectBody" | "onDeny" | "onDegraded" | "call" | "secureHeaders" | "responseGuard">): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: NextFn) => Promise<void>;
79
+
80
+ export { type ExpressLikeRequest, type ExpressLikeResponse, type ExpressZarexOptions, type NextFn, zarex };
@@ -0,0 +1,80 @@
1
+ import { Decision, ZarexOptions, SecureHeadersOptions, ResponseGuardOptions, ProtectCall, Zarex } from '@zarex/core';
2
+ export { CLOUDFLARE_RANGES, Decision, IpSource, PRIVATE_NETWORKS, ResponseGuardEvent, ResponseGuardOptions, Rule, SecureHeadersOptions, Zarex, ZarexOptions, createZarex, detectBot, emailValidation, phoneValidation, promptInjection, rateLimit, requestFilter, secureHeaders, sensitiveInfo, shield, tokenBucket, withSecureHeaders } from '@zarex/core';
3
+
4
+ /**
5
+ * Types minimaux d'Express : on ne dépend pas de `express` à l'exécution, donc
6
+ * le middleware fonctionne avec Express 4 et 5 (et tout framework compatible).
7
+ */
8
+ interface ExpressLikeRequest {
9
+ method: string;
10
+ originalUrl?: string;
11
+ url?: string;
12
+ ip?: string | undefined;
13
+ headers: Record<string, string | string[] | undefined>;
14
+ socket?: {
15
+ remoteAddress?: string | undefined;
16
+ } | undefined;
17
+ hostname?: string;
18
+ body?: unknown;
19
+ secure?: boolean;
20
+ app?: {
21
+ get?(setting: string): unknown;
22
+ };
23
+ zarex?: Decision;
24
+ }
25
+ interface ExpressLikeResponse {
26
+ status(code: number): this;
27
+ set(name: string, value: string): this;
28
+ json(body: unknown): unknown;
29
+ send?(body?: unknown): unknown;
30
+ get?(name: string): string | undefined;
31
+ getHeader?(name: string): unknown;
32
+ removeHeader?(name: string): void;
33
+ headersSent?: boolean;
34
+ }
35
+ type NextFn = (err?: unknown) => void;
36
+ interface ExpressZarexOptions extends Omit<ZarexOptions, "ipSource"> {
37
+ /**
38
+ * Défaut : "express" — l'adresse est celle qu'Express calcule (`req.ip`), selon
39
+ * `app.set("trust proxy", …)`. C'est la méthode la plus sûre avec Express :
40
+ * configurez « trust proxy » pour VOTRE infrastructure (voir la doc Express).
41
+ */
42
+ ipSource?: ZarexOptions["ipSource"];
43
+ /**
44
+ * Transmettre le corps de la requête au Shield (défaut false : il peut contenir
45
+ * des mots de passe). Nécessite que le middleware soit placé APRÈS express.json() /
46
+ * express.urlencoded(). Le SDK ne lit jamais le flux lui-même.
47
+ */
48
+ inspectBody?: boolean;
49
+ /**
50
+ * En-têtes de sécurité (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy…), ajoutés à TOUTES les réponses,
51
+ * y compris les refus. `true` = défauts prudents ; sinon voir SecureHeadersOptions. Retire aussi X-Powered-By.
52
+ */
53
+ secureHeaders?: SecureHeadersOptions | boolean;
54
+ /**
55
+ * Surveille les réponses de votre application (res.send / res.json) : masque (ou bloque) les cartes bancaires,
56
+ * clés d'API, jetons JWT, mots de passe… qui s'y trouveraient par erreur. Tout se passe sur votre serveur.
57
+ * LIMITE : ne couvre ni res.write()/res.end() directs, ni les flux (streams).
58
+ */
59
+ responseGuard?: ResponseGuardOptions | boolean;
60
+ /** Réponse personnalisée en cas de refus. Si absente : 429 (limite de débit) ou 403. */
61
+ onDeny?: (req: ExpressLikeRequest, res: ExpressLikeResponse, decision: Decision) => void;
62
+ /** Appelé quand le moteur n'a pas pu décider (la requête passe). */
63
+ onDegraded?: (req: ExpressLikeRequest, decision: Decision) => void;
64
+ /**
65
+ * Identifiants / jetons propres à chaque requête, ex. `(req) => ({ characteristics: { userId: req.user?.id } })`.
66
+ * Placez alors le middleware APRÈS votre authentification. Une erreur dans cette fonction est ignorée.
67
+ */
68
+ call?: (req: ExpressLikeRequest) => ProtectCall | undefined;
69
+ }
70
+ /**
71
+ * Middleware Express.
72
+ *
73
+ * app.use(zarex({ key: process.env.ZAREX_KEY!, baseUrl: process.env.ZAREX_URL!, rules: [shield(), rateLimit({ max: 100, window: "1m" })] }));
74
+ *
75
+ * Fail-open : si Zarex est injoignable, lent ou répond mal, la requête passe.
76
+ * Le résultat est disponible dans `req.zarex`.
77
+ */
78
+ declare function zarex(options: ExpressZarexOptions | Zarex, extra?: Pick<ExpressZarexOptions, "inspectBody" | "onDeny" | "onDegraded" | "call" | "secureHeaders" | "responseGuard">): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: NextFn) => Promise<void>;
79
+
80
+ export { type ExpressLikeRequest, type ExpressLikeResponse, type ExpressZarexOptions, type NextFn, zarex };
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ // src/index.ts
2
+ import {
3
+ applyGuard,
4
+ createZarex,
5
+ isLocalHost,
6
+ isScannableContentType,
7
+ normalizeGuardOptions,
8
+ prepareSecureHeaders
9
+ } from "@zarex/core";
10
+ import {
11
+ shield,
12
+ rateLimit,
13
+ tokenBucket,
14
+ detectBot,
15
+ requestFilter,
16
+ sensitiveInfo,
17
+ emailValidation,
18
+ phoneValidation,
19
+ promptInjection,
20
+ secureHeaders,
21
+ withSecureHeaders,
22
+ createZarex as createZarex2,
23
+ PRIVATE_NETWORKS,
24
+ CLOUDFLARE_RANGES
25
+ } from "@zarex/core";
26
+ var decoder = new TextDecoder("utf-8");
27
+ function installResponseGuard(res, guard) {
28
+ const original = res.send;
29
+ if (typeof original !== "function") return;
30
+ res.send = function guardedSend(body) {
31
+ let next = body;
32
+ try {
33
+ const ct = String((typeof res.get === "function" ? res.get("Content-Type") : void 0) ?? "");
34
+ const isString = typeof body === "string";
35
+ const isBytes = body instanceof Uint8Array;
36
+ if (isString && (ct === "" || isScannableContentType(ct)) || isBytes && isScannableContentType(ct)) {
37
+ const text = isString ? body : decoder.decode(body);
38
+ const r = applyGuard(text, ct || "text/html", guard);
39
+ if (r.action === "masked") next = r.body;
40
+ else if (r.action === "blocked" && !res.headersSent) {
41
+ res.status(500);
42
+ res.set("Content-Type", "application/json; charset=utf-8");
43
+ next = r.body;
44
+ }
45
+ }
46
+ } catch {
47
+ next = body;
48
+ }
49
+ return original.call(this, next);
50
+ };
51
+ }
52
+ function bodyToString(body) {
53
+ if (body === void 0 || body === null) return void 0;
54
+ if (typeof body === "string") return body;
55
+ if (typeof body === "object" && typeof body.toString === "function" && body.constructor?.name === "Buffer") {
56
+ return body.toString("utf8");
57
+ }
58
+ try {
59
+ const s = JSON.stringify(body);
60
+ return typeof s === "string" ? s : void 0;
61
+ } catch {
62
+ return void 0;
63
+ }
64
+ }
65
+ function defaultDeny(_req, res, d) {
66
+ const limited = d.deniedBy?.ruleType === "RATE_LIMIT" || d.deniedBy?.ruleType === "TOKEN_BUCKET";
67
+ if (res.headersSent) return;
68
+ if (limited) {
69
+ if (d.retryAfterSeconds !== void 0) res.set("Retry-After", String(d.retryAfterSeconds));
70
+ res.status(429).json({ error: { code: "rate_limited", message: "Too many requests", request_id: d.id } });
71
+ } else {
72
+ res.status(403).json({ error: { code: "forbidden", message: "Request blocked", request_id: d.id } });
73
+ }
74
+ }
75
+ function zarex(options, extra = {}) {
76
+ const isInstance = typeof options.protect === "function";
77
+ const opts = isInstance ? extra : options;
78
+ const instance = isInstance ? options : createZarex({ ...options, ipSource: options.ipSource ?? "express" });
79
+ const prepared = prepareSecureHeaders(opts.secureHeaders);
80
+ const guard = normalizeGuardOptions(opts.responseGuard);
81
+ return async function zarexMiddleware(req, res, next) {
82
+ let decision;
83
+ try {
84
+ if (prepared) {
85
+ const set = isLocalHost(req.hostname ?? (typeof req.headers["host"] === "string" ? req.headers["host"] : void 0)) ? prepared.http : prepared.https;
86
+ for (const [k, v] of Object.entries(set)) res.set(k, v);
87
+ if (prepared.removePoweredBy) res.removeHeader?.("X-Powered-By");
88
+ }
89
+ if (guard) installResponseGuard(res, guard);
90
+ } catch {
91
+ }
92
+ try {
93
+ let trustProxy;
94
+ try {
95
+ const v = req.app?.get?.("trust proxy");
96
+ trustProxy = v === void 0 ? void 0 : Boolean(v) && v !== "false";
97
+ } catch {
98
+ trustProxy = void 0;
99
+ }
100
+ const host = typeof req.headers["host"] === "string" ? req.headers["host"] : req.hostname;
101
+ const body = opts.inspectBody ? bodyToString(req.body) : void 0;
102
+ let call;
103
+ try {
104
+ call = opts.call?.(req);
105
+ } catch {
106
+ call = void 0;
107
+ }
108
+ decision = await instance.protect({
109
+ method: req.method,
110
+ path: req.originalUrl ?? req.url ?? "/",
111
+ host,
112
+ headers: req.headers,
113
+ socketIp: req.socket?.remoteAddress,
114
+ expressIp: req.ip,
115
+ expressTrustProxy: trustProxy,
116
+ body
117
+ }, call);
118
+ req.zarex = decision;
119
+ } catch {
120
+ next();
121
+ return;
122
+ }
123
+ if (decision.denied) {
124
+ try {
125
+ (opts.onDeny ?? defaultDeny)(req, res, decision);
126
+ } catch (err) {
127
+ next(err);
128
+ }
129
+ return;
130
+ }
131
+ if (decision.degraded && opts.onDegraded) {
132
+ try {
133
+ opts.onDegraded(req, decision);
134
+ } catch {
135
+ }
136
+ }
137
+ next();
138
+ };
139
+ }
140
+ export {
141
+ CLOUDFLARE_RANGES,
142
+ PRIVATE_NETWORKS,
143
+ createZarex2 as createZarex,
144
+ detectBot,
145
+ emailValidation,
146
+ phoneValidation,
147
+ promptInjection,
148
+ rateLimit,
149
+ requestFilter,
150
+ secureHeaders,
151
+ sensitiveInfo,
152
+ shield,
153
+ tokenBucket,
154
+ withSecureHeaders,
155
+ zarex
156
+ };
157
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import {\r\n applyGuard,\r\n createZarex,\r\n isLocalHost,\r\n isScannableContentType,\r\n normalizeGuardOptions,\r\n prepareSecureHeaders,\r\n type Decision,\r\n type ProtectCall,\r\n type ResponseGuardOptions,\r\n type SecureHeadersOptions,\r\n type Zarex,\r\n type ZarexOptions,\r\n} from \"@zarex/core\";\r\n\r\n/**\r\n * Types minimaux d'Express : on ne dépend pas de `express` à l'exécution, donc\r\n * le middleware fonctionne avec Express 4 et 5 (et tout framework compatible).\r\n */\r\nexport interface ExpressLikeRequest {\r\n method: string;\r\n originalUrl?: string;\r\n url?: string;\r\n ip?: string | undefined;\r\n headers: Record<string, string | string[] | undefined>;\r\n socket?: { remoteAddress?: string | undefined } | undefined;\r\n hostname?: string;\r\n body?: unknown;\r\n secure?: boolean;\r\n app?: { get?(setting: string): unknown };\r\n zarex?: Decision;\r\n}\r\nexport interface ExpressLikeResponse {\r\n status(code: number): this;\r\n set(name: string, value: string): this;\r\n json(body: unknown): unknown;\r\n send?(body?: unknown): unknown;\r\n get?(name: string): string | undefined;\r\n getHeader?(name: string): unknown;\r\n removeHeader?(name: string): void;\r\n headersSent?: boolean;\r\n}\r\nexport type NextFn = (err?: unknown) => void;\r\n\r\nexport interface ExpressZarexOptions extends Omit<ZarexOptions, \"ipSource\"> {\r\n /**\r\n * Défaut : \"express\" — l'adresse est celle qu'Express calcule (`req.ip`), selon\r\n * `app.set(\"trust proxy\", …)`. C'est la méthode la plus sûre avec Express :\r\n * configurez « trust proxy » pour VOTRE infrastructure (voir la doc Express).\r\n */\r\n ipSource?: ZarexOptions[\"ipSource\"];\r\n /**\r\n * Transmettre le corps de la requête au Shield (défaut false : il peut contenir\r\n * des mots de passe). Nécessite que le middleware soit placé APRÈS express.json() /\r\n * express.urlencoded(). Le SDK ne lit jamais le flux lui-même.\r\n */\r\n inspectBody?: boolean;\r\n /**\r\n * En-têtes de sécurité (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy…), ajoutés à TOUTES les réponses,\r\n * y compris les refus. `true` = défauts prudents ; sinon voir SecureHeadersOptions. Retire aussi X-Powered-By.\r\n */\r\n secureHeaders?: SecureHeadersOptions | boolean;\r\n /**\r\n * Surveille les réponses de votre application (res.send / res.json) : masque (ou bloque) les cartes bancaires,\r\n * clés d'API, jetons JWT, mots de passe… qui s'y trouveraient par erreur. Tout se passe sur votre serveur.\r\n * LIMITE : ne couvre ni res.write()/res.end() directs, ni les flux (streams).\r\n */\r\n responseGuard?: ResponseGuardOptions | boolean;\r\n /** Réponse personnalisée en cas de refus. Si absente : 429 (limite de débit) ou 403. */\r\n onDeny?: (req: ExpressLikeRequest, res: ExpressLikeResponse, decision: Decision) => void;\r\n /** Appelé quand le moteur n'a pas pu décider (la requête passe). */\r\n onDegraded?: (req: ExpressLikeRequest, decision: Decision) => void;\r\n /**\r\n * Identifiants / jetons propres à chaque requête, ex. `(req) => ({ characteristics: { userId: req.user?.id } })`.\r\n * Placez alors le middleware APRÈS votre authentification. Une erreur dans cette fonction est ignorée.\r\n */\r\n call?: (req: ExpressLikeRequest) => ProtectCall | undefined;\r\n}\r\n\r\nconst decoder = new TextDecoder(\"utf-8\");\r\n\r\n/** Enveloppe res.send : les corps TEXTE (string / octets texte) passent par la surveillance. Ne lève jamais. */\r\nfunction installResponseGuard(res: ExpressLikeResponse, guard: ResponseGuardOptions): void {\r\n const original = res.send;\r\n if (typeof original !== \"function\") return;\r\n res.send = function guardedSend(this: ExpressLikeResponse, body?: unknown): unknown {\r\n let next: unknown = body;\r\n try {\r\n const ct = String((typeof res.get === \"function\" ? res.get(\"Content-Type\") : undefined) ?? \"\");\r\n const isString = typeof body === \"string\";\r\n const isBytes = body instanceof Uint8Array;\r\n // Une chaîne sans type déclaré est du texte (Express la servira en text/html).\r\n if ((isString && (ct === \"\" || isScannableContentType(ct))) || (isBytes && isScannableContentType(ct))) {\r\n const text = isString ? (body as string) : decoder.decode(body as Uint8Array);\r\n const r = applyGuard(text, ct || \"text/html\", guard);\r\n if (r.action === \"masked\") next = r.body;\r\n else if (r.action === \"blocked\" && !res.headersSent) {\r\n res.status(500);\r\n res.set(\"Content-Type\", \"application/json; charset=utf-8\");\r\n next = r.body;\r\n }\r\n }\r\n } catch {\r\n next = body; // jamais casser la réponse de l'application\r\n }\r\n return (original as (b?: unknown) => unknown).call(this, next);\r\n };\r\n}\r\n\r\nfunction bodyToString(body: unknown): string | undefined {\r\n if (body === undefined || body === null) return undefined;\r\n if (typeof body === \"string\") return body;\r\n if (typeof body === \"object\" && typeof (body as { toString?: unknown }).toString === \"function\" && (body as { constructor?: { name?: string } }).constructor?.name === \"Buffer\") {\r\n return (body as { toString(enc: string): string }).toString(\"utf8\");\r\n }\r\n try {\r\n const s = JSON.stringify(body);\r\n return typeof s === \"string\" ? s : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n}\r\n\r\n/** Réponse de refus par défaut : générique, sans détail interne. */\r\nfunction defaultDeny(_req: ExpressLikeRequest, res: ExpressLikeResponse, d: Decision): void {\r\n const limited = d.deniedBy?.ruleType === \"RATE_LIMIT\" || d.deniedBy?.ruleType === \"TOKEN_BUCKET\";\r\n if (res.headersSent) return;\r\n if (limited) {\r\n if (d.retryAfterSeconds !== undefined) res.set(\"Retry-After\", String(d.retryAfterSeconds));\r\n res.status(429).json({ error: { code: \"rate_limited\", message: \"Too many requests\", request_id: d.id } });\r\n } else {\r\n res.status(403).json({ error: { code: \"forbidden\", message: \"Request blocked\", request_id: d.id } });\r\n }\r\n}\r\n\r\n/**\r\n * Middleware Express.\r\n *\r\n * app.use(zarex({ key: process.env.ZAREX_KEY!, baseUrl: process.env.ZAREX_URL!, rules: [shield(), rateLimit({ max: 100, window: \"1m\" })] }));\r\n *\r\n * Fail-open : si Zarex est injoignable, lent ou répond mal, la requête passe.\r\n * Le résultat est disponible dans `req.zarex`.\r\n */\r\nexport function zarex(options: ExpressZarexOptions | Zarex, extra: Pick<ExpressZarexOptions, \"inspectBody\" | \"onDeny\" | \"onDegraded\" | \"call\" | \"secureHeaders\" | \"responseGuard\"> = {}) {\r\n const isInstance = typeof (options as Zarex).protect === \"function\";\r\n const opts = (isInstance ? extra : options) as ExpressZarexOptions;\r\n const instance: Zarex = isInstance\r\n ? (options as Zarex)\r\n : createZarex({ ...(options as ExpressZarexOptions), ipSource: (options as ExpressZarexOptions).ipSource ?? \"express\" });\r\n\r\n // Préparés UNE fois : une option invalide est signalée au démarrage, pas à la première requête.\r\n const prepared = prepareSecureHeaders(opts.secureHeaders);\r\n const guard = normalizeGuardOptions(opts.responseGuard);\r\n\r\n return async function zarexMiddleware(req: ExpressLikeRequest, res: ExpressLikeResponse, next: NextFn): Promise<void> {\r\n let decision: Decision | undefined;\r\n try {\r\n if (prepared) {\r\n const set = isLocalHost(req.hostname ?? (typeof req.headers[\"host\"] === \"string\" ? (req.headers[\"host\"] as string) : undefined)) ? prepared.http : prepared.https;\r\n for (const [k, v] of Object.entries(set)) res.set(k, v);\r\n if (prepared.removePoweredBy) res.removeHeader?.(\"X-Powered-By\");\r\n }\r\n if (guard) installResponseGuard(res, guard);\r\n } catch {\r\n /* les protections de réponse ne doivent jamais casser la requête */\r\n }\r\n try {\r\n let trustProxy: boolean | undefined;\r\n try {\r\n const v = req.app?.get?.(\"trust proxy\");\r\n trustProxy = v === undefined ? undefined : Boolean(v) && v !== \"false\";\r\n } catch {\r\n trustProxy = undefined;\r\n }\r\n const host = typeof req.headers[\"host\"] === \"string\" ? (req.headers[\"host\"] as string) : req.hostname;\r\n const body = opts.inspectBody ? bodyToString(req.body) : undefined;\r\n let call: ProtectCall | undefined;\r\n try {\r\n call = opts.call?.(req);\r\n } catch {\r\n call = undefined;\r\n }\r\n decision = await instance.protect({\r\n method: req.method,\r\n path: req.originalUrl ?? req.url ?? \"/\",\r\n host,\r\n headers: req.headers,\r\n socketIp: req.socket?.remoteAddress,\r\n expressIp: req.ip,\r\n expressTrustProxy: trustProxy,\r\n body,\r\n }, call);\r\n req.zarex = decision;\r\n } catch {\r\n // protect() ne lève pas, mais quoi qu'il arrive : la requête du client passe.\r\n next();\r\n return;\r\n }\r\n\r\n if (decision.denied) {\r\n try {\r\n (opts.onDeny ?? defaultDeny)(req, res, decision);\r\n } catch (err) {\r\n // Toujours propager par next(err), même si des en-têtes ont déjà été écrits : Express gère ce\r\n // cas lui-même (son gestionnaire d'erreur par défaut referme la connexion sans tenter d'écrire\r\n // une 2ᵉ réponse). Le garde-fou précédent (`if (!res.headersSent)`) empêchait tout appel à\r\n // next() dans ce cas précis : un onDeny qui écrivait partiellement la réponse puis levait\r\n // laissait la requête bloquée indéfiniment, sans aucune trace (audit du 04/11/2026, voir\r\n // AUDIT_SECURITE_COMPLET.md).\r\n next(err);\r\n }\r\n return;\r\n }\r\n if (decision.degraded && opts.onDegraded) {\r\n try {\r\n opts.onDegraded(req, decision);\r\n } catch {\r\n /* le rappel du développeur ne doit pas casser la requête */\r\n }\r\n }\r\n next();\r\n };\r\n}\r\n\r\nexport {\r\n shield,\r\n rateLimit,\r\n tokenBucket,\r\n detectBot,\r\n requestFilter,\r\n sensitiveInfo,\r\n emailValidation,\r\n phoneValidation,\r\n promptInjection,\r\n secureHeaders,\r\n withSecureHeaders,\r\n createZarex,\r\n PRIVATE_NETWORKS,\r\n CLOUDFLARE_RANGES,\r\n} from \"@zarex/core\";\r\nexport type { SecureHeadersOptions, ResponseGuardOptions, ResponseGuardEvent } from \"@zarex/core\";\r\nexport type { Decision, Zarex, ZarexOptions, IpSource, Rule } from \"@zarex/core\";"],"mappings":";AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AAmNP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAAA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAhKP,IAAM,UAAU,IAAI,YAAY,OAAO;AAGvC,SAAS,qBAAqB,KAA0B,OAAmC;AACzF,QAAM,WAAW,IAAI;AACrB,MAAI,OAAO,aAAa,WAAY;AACpC,MAAI,OAAO,SAAS,YAAuC,MAAyB;AAClF,QAAI,OAAgB;AACpB,QAAI;AACF,YAAM,KAAK,QAAQ,OAAO,IAAI,QAAQ,aAAa,IAAI,IAAI,cAAc,IAAI,WAAc,EAAE;AAC7F,YAAM,WAAW,OAAO,SAAS;AACjC,YAAM,UAAU,gBAAgB;AAEhC,UAAK,aAAa,OAAO,MAAM,uBAAuB,EAAE,MAAQ,WAAW,uBAAuB,EAAE,GAAI;AACtG,cAAM,OAAO,WAAY,OAAkB,QAAQ,OAAO,IAAkB;AAC5E,cAAM,IAAI,WAAW,MAAM,MAAM,aAAa,KAAK;AACnD,YAAI,EAAE,WAAW,SAAU,QAAO,EAAE;AAAA,iBAC3B,EAAE,WAAW,aAAa,CAAC,IAAI,aAAa;AACnD,cAAI,OAAO,GAAG;AACd,cAAI,IAAI,gBAAgB,iCAAiC;AACzD,iBAAO,EAAE;AAAA,QACX;AAAA,MACF;AAAA,IACF,QAAQ;AACN,aAAO;AAAA,IACT;AACA,WAAQ,SAAsC,KAAK,MAAM,IAAI;AAAA,EAC/D;AACF;AAEA,SAAS,aAAa,MAAmC;AACvD,MAAI,SAAS,UAAa,SAAS,KAAM,QAAO;AAChD,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,OAAO,SAAS,YAAY,OAAQ,KAAgC,aAAa,cAAe,KAA6C,aAAa,SAAS,UAAU;AAC/K,WAAQ,KAA2C,SAAS,MAAM;AAAA,EACpE;AACA,MAAI;AACF,UAAM,IAAI,KAAK,UAAU,IAAI;AAC7B,WAAO,OAAO,MAAM,WAAW,IAAI;AAAA,EACrC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,YAAY,MAA0B,KAA0B,GAAmB;AAC1F,QAAM,UAAU,EAAE,UAAU,aAAa,gBAAgB,EAAE,UAAU,aAAa;AAClF,MAAI,IAAI,YAAa;AACrB,MAAI,SAAS;AACX,QAAI,EAAE,sBAAsB,OAAW,KAAI,IAAI,eAAe,OAAO,EAAE,iBAAiB,CAAC;AACzF,QAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,SAAS,qBAAqB,YAAY,EAAE,GAAG,EAAE,CAAC;AAAA,EAC1G,OAAO;AACL,QAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,SAAS,mBAAmB,YAAY,EAAE,GAAG,EAAE,CAAC;AAAA,EACrG;AACF;AAUO,SAAS,MAAM,SAAsC,QAAyH,CAAC,GAAG;AACvL,QAAM,aAAa,OAAQ,QAAkB,YAAY;AACzD,QAAM,OAAQ,aAAa,QAAQ;AACnC,QAAM,WAAkB,aACnB,UACD,YAAY,EAAE,GAAI,SAAiC,UAAW,QAAgC,YAAY,UAAU,CAAC;AAGzH,QAAM,WAAW,qBAAqB,KAAK,aAAa;AACxD,QAAM,QAAQ,sBAAsB,KAAK,aAAa;AAEtD,SAAO,eAAe,gBAAgB,KAAyB,KAA0B,MAA6B;AACpH,QAAI;AACJ,QAAI;AACF,UAAI,UAAU;AACZ,cAAM,MAAM,YAAY,IAAI,aAAa,OAAO,IAAI,QAAQ,MAAM,MAAM,WAAY,IAAI,QAAQ,MAAM,IAAe,OAAU,IAAI,SAAS,OAAO,SAAS;AAC5J,mBAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,GAAG,EAAG,KAAI,IAAI,GAAG,CAAC;AACtD,YAAI,SAAS,gBAAiB,KAAI,eAAe,cAAc;AAAA,MACjE;AACA,UAAI,MAAO,sBAAqB,KAAK,KAAK;AAAA,IAC5C,QAAQ;AAAA,IAER;AACA,QAAI;AACF,UAAI;AACJ,UAAI;AACF,cAAM,IAAI,IAAI,KAAK,MAAM,aAAa;AACtC,qBAAa,MAAM,SAAY,SAAY,QAAQ,CAAC,KAAK,MAAM;AAAA,MACjE,QAAQ;AACN,qBAAa;AAAA,MACf;AACA,YAAM,OAAO,OAAO,IAAI,QAAQ,MAAM,MAAM,WAAY,IAAI,QAAQ,MAAM,IAAe,IAAI;AAC7F,YAAM,OAAO,KAAK,cAAc,aAAa,IAAI,IAAI,IAAI;AACzD,UAAI;AACJ,UAAI;AACF,eAAO,KAAK,OAAO,GAAG;AAAA,MACxB,QAAQ;AACN,eAAO;AAAA,MACT;AACA,iBAAW,MAAM,SAAS,QAAQ;AAAA,QAChC,QAAQ,IAAI;AAAA,QACZ,MAAM,IAAI,eAAe,IAAI,OAAO;AAAA,QACpC;AAAA,QACA,SAAS,IAAI;AAAA,QACb,UAAU,IAAI,QAAQ;AAAA,QACtB,WAAW,IAAI;AAAA,QACf,mBAAmB;AAAA,QACnB;AAAA,MACF,GAAG,IAAI;AACP,UAAI,QAAQ;AAAA,IACd,QAAQ;AAEN,WAAK;AACL;AAAA,IACF;AAEA,QAAI,SAAS,QAAQ;AACnB,UAAI;AACF,SAAC,KAAK,UAAU,aAAa,KAAK,KAAK,QAAQ;AAAA,MACjD,SAAS,KAAK;AAOZ,aAAK,GAAG;AAAA,MACV;AACA;AAAA,IACF;AACA,QAAI,SAAS,YAAY,KAAK,YAAY;AACxC,UAAI;AACF,aAAK,WAAW,KAAK,QAAQ;AAAA,MAC/B,QAAQ;AAAA,MAER;AAAA,IACF;AACA,SAAK;AAAA,EACP;AACF;","names":["createZarex"]}
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@zarex/express",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Zarex — middleware Express",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "main": "./dist/index.cjs",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "require": {
20
+ "types": "./dist/index.d.cts",
21
+ "default": "./dist/index.cjs"
22
+ }
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "sideEffects": false,
29
+ "dependencies": {
30
+ "@zarex/core": "^0.1.1"
31
+ },
32
+ "peerDependencies": {
33
+ "express": ">=4"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "express": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "scripts": {
41
+ "build": "tsup"
42
+ }
6
43
  }