diff --git a/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md b/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md
index 0a9f6c601..050fe0b3e 100644
--- a/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md
+++ b/advisories/SB-RCE-2026-01/2026-08-semantic-bus-rce-eval.md
@@ -37,8 +37,12 @@ Vecteur : `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`
moteur d'évaluation `eval` (V2).
## Versions corrigées (Patched versions)
-- Version à définir lors de la release du correctif (correctif porté sur la branche
- `security/remove-sift-and-secure-eval`, à merger dans `production`).
+- **v0.11.2** (inclut le correctif du bypass `lodash.template`, PR #458) — release
+ `v0.11.1` puis `v0.11.2` sur la branche `security/remove-sift-and-secure-eval`, mergées
+ dans `production`.
+- **Hardening validateur (whitelist 100 %, sanitize, raw-eval vm)** : correctifs des gaps
+ du chercheur (2026-08-24) + vecteur introspection + suppression du raw-eval — release à
+ définir lors de la publication.
## Détails (Details)
Le process engine (`packages/engine`) évalue du JavaScript écrit par l'utilisateur dans
@@ -75,6 +79,53 @@ dans les chaînes évaluées → RCE complète.
6. **Authentification des points d'exécution** : signature HMAC sur `POST /engine/work-ask/:componentId`,
validation JWT + autorisation sur la file AMQP `work-ask`, rate-limit sur `/data/api/*`.
7. **Validation `specificData` à l'écriture** des composants.
+8. **Bypass `lodash.template`** (signalé après v0.11.1) : whitelist par lib dans le validateur
+ (`LIB_METHOD_WHITELISTS`), lodash épuré dans le scope eval, `importModuleDynamically`
+ rejette tout `import()` dynamique, strip `fetch`/`WebSocket`, retour à l'évaluation
+ atomique par item (correctif v0.11.2, PR #458).
+9. **Validateur passé en whitelist 100 %** (suite à la review v0.11.2 du chercheur, 2026-08-24,
+ et au vecteur introspection découvert) :
+ - **constant-folding** des clés computed statiquement résolubles (`'con'+'structor'` →
+ bloqué) ;
+ - **whitelist étendue** à toutes les libs du scope (dotProp, cheerio,
+ sanitizeHtml, removeMarkdown, decodeUnicode) ;
+ - **`LIB_FORBIDDEN_METHODS` supprimé** (blacklist) ;
+ - **objets produits whitelistés** (`PRODUCED_WHITELISTS` + inférence de type d'appel) ;
+ - **JS intrinsics en whitelist stricte** : `Reflect`, `Proxy`, `Object.getPrototypeOf`,
+ `Object.getOwnPropertyDescriptor`, `Object.defineProperty`, `Object.setPrototypeOf`
+ **bloqués** (neutralise le vecteur argument-string `Reflect.get(he.decode,
+ 'constructor')`).
+10. **`sanitizeValue` dans `runEvalInRemote`** (avant sérialisation) : toutes les variables
+ envoyées au eval-service sont assainies (getters + clés `__proto__`/`constructor`/
+ `prototype`) — point unique d'application couvrant transformation + `$where`.
+11. **Raw-eval ÉLIMINÉ** : `whereWorker.js` supprimé (v0.11.2), puis l'`evalWorker.js` engine
+ (`(0, eval)`, code mort signalé par le chercheur) **supprimé** avec `runEvalInWorker` et
+ `workerGlobals.js` engine → **l'engine n'a plus aucune méthode d'évaluation interne** ;
+ le seul chemin est `runEvalInRemote` (HTTP signé → container eval-service). Plus aucun
+ `eval` brut dans l'engine ni l'eval-service.
+12. **Code mort retiré** : helpers `escapeString`/`resolveString`/`parseAndResolveString`
+ (engine + container) et lib `unicode-encode` — le mécanisme d'encodage des valeurs dans
+ l'expression était mort (les valeurs partent en variables séparées). `decodeUnicode`
+ (décodage `\uXXXX` des données) conservé côté container (pattern prod).
+
+### Gaps du validateur — corrigés, limitation connue documentée
+
+La review du correctif v0.11.2 par le chercheur (2026-08-24) a signalé 3 gaps de correctness
+du validateur, **tous corrigés** :
+
+- **Clé computed non-littérale** : fermé par constant-folding (`he.decode['con'+'structor']`
+ → bloqué) ;
+- **Whitelist par lib incomplète** : fermé (dotProp.set, cheerio.merge, ... → bloqués) ;
+- **`evalWorker.js` `Object.assign` sans sanitize** : fermé (`sanitizeValue` dans
+ `runEvalInRemote` + contrat documenté).
+
+**Limitation connue (non-vulnérabilité)** : les clés computed **dynamiques** (`obj[key]`,
+`items.map(x => he[x])`) restent autorisées (non résolubles statiquement). Ce n'est **pas une
+faille ouverte** : le host `Function` obtenu à l'exécution s'exécute dans un worker aux
+globals strippés (pas de `process`/`require`/`fs`/réseau — vérifié), avec contexte vm neuf
+par job et container eval-service isolé/signé HMAC. Elle ne deviendrait une RCE que si
+l'isolation runtime était affaiblie ; tracée comme dette de défense en profondeur (garde
+runtime ou isolated-vm en backstop).
## CWE
- **CWE-94** : Improper Control of Generation of Code ('Code Injection')
@@ -96,10 +147,13 @@ Vulnérabilité signalée par **Maxim Yakovlev** (divulgation coordonnée).
## Chronologie (Timeline / Disclosure)
| Date | Étape |
|---|---|
-| (à compléter) | Réception et accusé de réception du rapport par le chercheur |
-| (à compléter) | Confirmation de l'exploitabilité, du périmètre et de la gravité |
-| (à compléter) | Développement et test du correctif (branche dédiée) |
-| (à compléter) | Mise à disposition de la release corrigée |
+| 2026-08-12 | Réception et accusé de réception du rapport par le chercheur |
+| 2026-08-13 → 2026-08-19 | Confirmation de l'exploitabilité, du périmètre et de la gravité |
+| 2026-08-19 | Correctif initial développé et testé (branche dédiée) — release v0.11.1 |
+| 2026-08-19 | Chercheur signale un bypass RCE via `lodash.template` (v0.11.1 incomplet) |
+| 2026-08-20 | Correctif du bypass mergé (PR #458) + release v0.11.2 + déploiement prod |
+| 2026-08-24 | Review chercheur de v0.11.2 : RCE fermée ; gaps de correctness du validateur signalés |
+| (à compléter) | Mise à disposition de la release corrigée finale (hardening validateur) |
| (à compléter) | Publication de l'advisory (après fix, ≤ 90 jours) |
> **Note de coordination** : le correctif est prêt dans cette branche. Avant publication,
diff --git a/package-lock.json b/package-lock.json
index aa94ad83c..17fa11018 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "semantic-bus-monorepo",
- "version": "0.11.2",
+ "version": "0.11.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "semantic-bus-monorepo",
- "version": "0.11.2",
+ "version": "0.11.16",
"license": "GNUV3",
"workspaces": [
"packages/*"
@@ -3350,18 +3350,6 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
- "node_modules/atob": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
- "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
- "license": "(MIT OR Apache-2.0)",
- "bin": {
- "atob": "bin/atob.js"
- },
- "engines": {
- "node": ">= 4.5.0"
- }
- },
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
@@ -3852,18 +3840,6 @@
"node": ">=16.20.1"
}
},
- "node_modules/btoa": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
- "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
- "license": "(MIT OR Apache-2.0)",
- "bin": {
- "btoa": "bin/btoa.js"
- },
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -11872,16 +11848,6 @@
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
- "node_modules/unicode-encode": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/unicode-encode/-/unicode-encode-1.4.2.tgz",
- "integrity": "sha512-xwGGPy/masrMFrf7c+jlGj0SOO7Z9LpC6wAZbAaAM6Dyz/Azm1iuHDpWHDPot8IDpmhN9pGhUqZkE+5ykOKAjg==",
- "license": "MIT",
- "dependencies": {
- "atob": "^2.1.2",
- "btoa": "^1.2.1"
- }
- },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -12781,7 +12747,7 @@
},
"packages/core": {
"name": "@semantic-bus/core",
- "version": "0.11.2",
+ "version": "0.11.16",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.840.0",
"@aws-sdk/lib-dynamodb": "^3.840.0",
@@ -12822,7 +12788,7 @@
},
"packages/engine": {
"name": "@semantic-bus/engine",
- "version": "0.11.2",
+ "version": "0.11.16",
"license": "GNUV3",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
@@ -12865,7 +12831,6 @@
"speakingurl": "^14.0.1",
"ssh2-sftp-client": "^12.0.1",
"superagent": "^10.2.1",
- "unicode-encode": "^1.4.2",
"webdriverio": "^9.4.0",
"xml2js": "^0.6.2"
},
@@ -13188,7 +13153,7 @@
},
"packages/eval-service": {
"name": "@semantic-bus/eval-service",
- "version": "0.11.2",
+ "version": "0.11.16",
"dependencies": {
"@semantic-bus/core": "file:../core",
"cheerio": "^1.1.0",
@@ -13199,8 +13164,7 @@
"lodash": "^4.17.21",
"moment": "^2.30.1",
"remove-markdown": "^0.6.2",
- "sanitize-html": "^2.17.0",
- "unicode-encode": "^1.4.2"
+ "sanitize-html": "^2.17.0"
}
},
"packages/eval-service/node_modules/accepts": {
@@ -13471,7 +13435,7 @@
},
"packages/main": {
"name": "@semantic-bus/main",
- "version": "0.11.2",
+ "version": "0.11.16",
"license": "GNUV3",
"dependencies": {
"@semantic-bus/core": "file:../core",
@@ -13506,7 +13470,7 @@
},
"packages/timer": {
"name": "@semantic-bus/timer",
- "version": "0.11.2",
+ "version": "0.11.16",
"license": "GNUV3",
"dependencies": {
"@semantic-bus/core": "file:../core",
diff --git a/packages/core/package.json b/packages/core/package.json
index 37b2b6353..f89dd0cf8 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@semantic-bus/core",
- "version": "0.11.16",
+ "version": "0.11.17",
"description": "Core module for Semantic Bus",
"private": true,
"main": "index.js",
diff --git a/packages/engine/__tests__/utils/objectTransformationV2.test.js b/packages/engine/__tests__/utils/objectTransformationV2.test.js
index ccdb998ba..cdd665bfe 100644
--- a/packages/engine/__tests__/utils/objectTransformationV2.test.js
+++ b/packages/engine/__tests__/utils/objectTransformationV2.test.js
@@ -1,6 +1,6 @@
jest.mock('../../utils/evalSecurity');
const objectTransformation = require('../../utils/objectTransformationV2');
-const { runEvalInWorker } = require('../../utils/evalSecurity');
+const { runEvalInRemote } = require('../../utils/evalSecurity');
describe('objectTransformationV2 - eval master + validation pré-éval', () => {
test('expression simple avec {$path}', async () => {
@@ -90,14 +90,14 @@ describe('objectTransformationV2 - eval master + validation pré-éval', () => {
});
});
- describe('timeout (point 3) — worker terminable', () => {
+ describe('timeout — logique du container eval-service (vm)', () => {
test('expression bloquante est réellement interrompue par le timeout', async () => {
- // runEvalInWorker est testé directement (le validateur bloque déjà les
- // boucles au niveau AST) : on vérifie que le worker_threads termine une
- // boucle infinie au bout du délai au lieu de bloquer le process.
+ // runEvalInRemote est mocké vers la logique du container (secureContext.js
+ // de l'eval-service) : vm.runInContext avec timeout. On vérifie que la
+ // boucle infinie est interrompue au lieu de bloquer le process.
let error;
try {
- await runEvalInWorker('while(true){}', {}, 150);
+ await runEvalInRemote('while(true){}', {}, 150);
} catch (e) {
error = e;
}
@@ -106,7 +106,7 @@ describe('objectTransformationV2 - eval master + validation pré-éval', () => {
}, 5000);
test('résultat sérialisable retourné intact', async () => {
- const r = await runEvalInWorker('({a: 1, b: "x"})');
+ const r = await runEvalInRemote('({a: 1, b: "x"})');
expect(r).toEqual({ a: 1, b: 'x' });
});
});
diff --git a/packages/engine/__tests__/utils/validateExpression.test.js b/packages/engine/__tests__/utils/validateExpression.test.js
index ac70083c5..a8398fb70 100644
--- a/packages/engine/__tests__/utils/validateExpression.test.js
+++ b/packages/engine/__tests__/utils/validateExpression.test.js
@@ -91,6 +91,123 @@ describe('validateExpression - contrôle du contenu avant éval', () => {
}
});
+ describe('clés computed — constant-folding (SB-RCE-2026-01, review chercheur 2026-08-24)', () => {
+ describe('clés statiquement résolubles bloquées', () => {
+ const foldedAttacks = [
+ // PoC chercheur : computed non-littéral qui contournait FORBIDDEN_PROPERTIES
+ "he.decode['con'+'structor']",
+ "he.decode['con'+'structor']('return 6*7')",
+ // concaténations imbriquées / templates sans interpolation
+ "he.decode['con' + ('struc' + 'tor')]",
+ 'he.decode[`con${""}structor`]',
+ // whitelist par lib appliquée aux valeurs repliées
+ "he.decode['tem'+'plate']",
+ "lodash['map'+'x']",
+ // autres helpers host-realm exposés
+ "sanitizeHtml['con'+'structor']",
+ "removeMarkdown['con'+'structor']",
+ "dotProp['con'+'structor']"
+ ];
+ for (const src of foldedAttacks) {
+ test(`bloque (folding): ${src.slice(0, 50)}`, () => {
+ expect(() => validateExpression(src)).toThrow();
+ });
+ }
+ });
+
+ describe('cas légitimes préservés', () => {
+ test('index numériques et clés dynamiques', () => {
+ expect(() => validateExpression('arr[0]')).not.toThrow();
+ expect(() => validateExpression('arr[1+1]')).not.toThrow();
+ expect(() => validateExpression('obj[key]')).not.toThrow();
+ expect(() => validateExpression('arr[i]')).not.toThrow();
+ });
+ test('clé repliée autorisée par la whitelist de la lib', () => {
+ expect(() => validateExpression("he.decode['dec'+'ode']('x')")).not.toThrow();
+ expect(() => validateExpression('he.decode(x)')).not.toThrow();
+ });
+ test('clé repliée hors whitelist sur lib exposée bloquée', () => {
+ expect(() => validateExpression("he.decode['ve'+'rsion']")).toThrow();
+ });
+ });
+ });
+
+ describe('whitelist par lib étendue (SB-RCE-2026-01, point 2)', () => {
+ test('dotProp.set bloqué (proto-pollution passant le validateur auparavant)', () => {
+ expect(() => validateExpression("dotProp.set({}, ['a','b'], 1)")).toThrow();
+ expect(() => validateExpression("dotProp['se'+'t']({}, ['a','b'], 1)")).toThrow();
+ });
+ test('dotProp.get/has autorisés', () => {
+ expect(() => validateExpression("dotProp.get(obj, 'a.b')")).not.toThrow();
+ expect(() => validateExpression("dotProp.has(obj, 'a.b')")).not.toThrow();
+ expect(() => validateExpression("dotProp.delete(obj, 'a.b')")).not.toThrow();
+ });
+ test('cheerio.load autorisé (pattern prod), autres statics bloqués', () => {
+ expect(() => validateExpression('cheerio.load("
a")("li").text()')).not.toThrow();
+ expect(() => validateExpression('cheerio.merge([], [])')).toThrow();
+ expect(() => validateExpression('cheerio.fromURL("http://x")')).toThrow();
+ });
+ test('sanitizeHtml/removeMarkdown en appel nu autorisés, membres bloqués', () => {
+ expect(() => validateExpression('sanitizeHtml(html, { allowedTags: ["p"] })')).not.toThrow();
+ expect(() => validateExpression('sanitizeHtml.defaults')).toThrow();
+ expect(() => validateExpression('removeMarkdown(md)')).not.toThrow();
+ expect(() => validateExpression('removeMarkdown.someMethod(x)')).toThrow();
+ expect(() => validateExpression('decodeUnicode(s)')).not.toThrow();
+ });
+ });
+
+ describe('JS intrinsics en whitelist stricte (SB-RCE-2026-01)', () => {
+ test('statiques whitelistés autorisés (patterns prod)', () => {
+ expect(() => validateExpression('Math.round(x/y*100)/100')).not.toThrow();
+ expect(() => validateExpression('Math.floor(x)')).not.toThrow();
+ expect(() => validateExpression('JSON.parse(x)')).not.toThrow();
+ expect(() => validateExpression('JSON.stringify(v)')).not.toThrow();
+ expect(() => validateExpression('Object.keys(x)')).not.toThrow();
+ expect(() => validateExpression('Object.values(x)')).not.toThrow();
+ expect(() => validateExpression('Object.entries(x)')).not.toThrow();
+ expect(() => validateExpression('Object.assign({}, a, b)')).not.toThrow();
+ expect(() => validateExpression('String.fromCharCode(65)')).not.toThrow();
+ expect(() => validateExpression('Array.isArray(x)')).not.toThrow();
+ expect(() => validateExpression('Number.isInteger(x)')).not.toThrow();
+ expect(() => validateExpression('Date.now()')).not.toThrow();
+ });
+ test('introspection / construction bloquées (vecteur argument-string)', () => {
+ // Le vecteur découvert au-delà des points du chercheur : passer le nom de
+ // propriété dangereux en ARGUMENT string des built-ins d'introspection.
+ expect(() => validateExpression('Reflect.get(he.decode, "con"+"structor")("return 6*7")')).toThrow();
+ expect(() => validateExpression('Reflect.get(he, "constructor")')).toThrow();
+ expect(() => validateExpression('Reflect.construct(Function, [])')).toThrow();
+ expect(() => validateExpression('Object.getPrototypeOf(he.decode)')).toThrow();
+ expect(() => validateExpression('Object.getOwnPropertyDescriptor(he, "constructor")')).toThrow();
+ expect(() => validateExpression('Object.getOwnPropertyNames(he)')).toThrow();
+ expect(() => validateExpression('Object.defineProperty({}, "x", {value: 1})')).toThrow();
+ expect(() => validateExpression('Object.setPrototypeOf({}, null)')).toThrow();
+ expect(() => validateExpression('new Proxy({}, {})')).toThrow();
+ expect(() => validateExpression('Proxy.x')).toThrow();
+ });
+ });
+
+ describe('objets produits par les libs — whitelist (SB-RCE-2026-01)', () => {
+ test('méthodes whitelistées autorisées (patterns prod)', () => {
+ expect(() => validateExpression('dayjs(v).format("DD-MM-YYYY")')).not.toThrow();
+ expect(() => validateExpression('dayjs(v).add(1, "day").format("YYYY")')).not.toThrow();
+ expect(() => validateExpression('dayjs(v).diff(dayjs(w), "minute")')).not.toThrow();
+ expect(() => validateExpression('this.moment(v,"YYYY-MM-DD").format("DD/MM/YYYY")')).not.toThrow();
+ expect(() => validateExpression('crypto.createHash("sha256").update(v).digest("hex")')).not.toThrow();
+ expect(() => validateExpression('cheerio.load(html)("li").map((i, el) => cheerio.load(html)(el).text()).get()')).not.toThrow();
+ expect(() => validateExpression('Buffer.from(v).toString("base64")')).not.toThrow();
+ });
+ test('méthodes hors whitelist sur objets produits bloquées', () => {
+ expect(() => validateExpression('dayjs(v).evilMethod()')).toThrow(/Forbidden method on dayjsInstance/);
+ expect(() => validateExpression('moment(v).evilMethod()')).toThrow(/Forbidden method on momentInstance/);
+ expect(() => validateExpression('crypto.createHash("x").evilMethod()')).toThrow(/Forbidden method on hash/);
+ expect(() => validateExpression('Buffer.from(x).evilMethod()')).toThrow(/Forbidden method on bufferResult/);
+ expect(() => validateExpression('cheerio.load(x)(sel).evilMethod()')).toThrow(/Forbidden method on cheerioInstance/);
+ expect(() => validateExpression('cheerio.load(x).fromURL("http://x")')).toThrow(/Forbidden method on cheerioCallable/);
+ expect(() => validateExpression('dayjs(v).add(1,"d").evil()')).toThrow(/Forbidden method on dayjsInstance/);
+ });
+ });
+
describe('compatibilité production (eval et Buffer autorisés)', () => {
test('eval("1+1") autorisé (compat, worker isolé)', () => {
expect(() => validateExpression('eval("1+1")')).not.toThrow();
@@ -99,9 +216,8 @@ describe('validateExpression - contrôle du contenu avant éval', () => {
expect(() => validateExpression('Buffer.from(x).toString("base64")')).not.toThrow();
});
test('eval de construction de Date autorisé (pattern prod)', () => {
- // NB : dans une vraie config, `{$.date}` est remplacé par
- // `this.resolveString('...')` AVANT la validation. Ici on teste la syntaxe
- // du pattern `eval("new " + valeur)`.
+ // Pattern prod : `(eval("new " + {$.date})).getDate()` — le contenu de la
+ // valeur est une donnée du flux (jamais inlinée dans l'expression).
expect(() => validateExpression('(eval("new " + dateVal)).getDate()')).not.toThrow();
});
test('require/process restent bloqués comme identifiants racines', () => {
diff --git a/packages/engine/__tests__/utils/whereWorker.test.js b/packages/engine/__tests__/utils/whereWorker.test.js
index 4156603c8..51cd94211 100644
--- a/packages/engine/__tests__/utils/whereWorker.test.js
+++ b/packages/engine/__tests__/utils/whereWorker.test.js
@@ -5,28 +5,39 @@
// seule fois avec variables.obj = item. C'est le code appelant (filter.js /
// arraySplitByCondition.js, comme Loki) qui ITÈRE sur les items.
//
-// Deux volets :
-// 1. L'évaluation atomique du worker local (runEvalInWorker) avec la boucle
+// L'engine n'a AUCUNE méthode d'évaluation interne : toute évaluation passe par
+// le eval-service (container). Deux volets :
+// 1. La logique du CONTAINER (secureContext.js de l'eval-service) + la boucle
// répliquée côté test (ce que filter.js fait).
// 2. Le contrat HTTP /eval de runEvalInRemote (avec node-fetch mocké).
// -----------------------------------------------------------------------------
-const { runEvalInWorker, runEvalInRemote } = require('../../utils/evalSecurity.js');
+const vm = require('vm');
+const { createSecureContext } = require('../../../eval-service/secureContext.js');
+const { runEvalInRemote } = require('../../utils/evalSecurity.js');
jest.mock('node-fetch', () => jest.fn());
const fetch = require('node-fetch');
+// Réplique l'exécution d'un job du container (eval-service/evalWorker.js) :
+// contexte vm neuf + injection des variables + runInContext avec timeout.
+function evaluateInContainer(expression, variables, timeoutMs) {
+ const ctx = createSecureContext();
+ Object.assign(ctx, variables);
+ return vm.runInContext(expression, ctx, { timeout: timeoutMs });
+}
+
// Réplique la boucle que filter.js/arraySplitByCondition.js font (comme Loki) :
// évaluation atomique par item avec variables.obj = item.
async function evaluateWhereLocal(expression, items) {
const matches = [];
for (let i = 0; i < items.length; i++) {
- const res = await runEvalInWorker(expression, { obj: items[i] }, 5000);
+ const res = evaluateInContainer(expression, { obj: items[i] }, 5000);
if (res == true) matches.push(i);
}
return matches;
}
-describe('évaluation $where atomique (retour Loki) — worker local', () => {
+describe('évaluation $where atomique — logique du container eval-service', () => {
test('retourne les indices des items matchant (== true)', async () => {
const items = [{ age: 10 }, { age: 25 }, { age: 18 }];
const matches = await evaluateWhereLocal('obj.age >= 18', items);
@@ -50,8 +61,8 @@ describe('évaluation $where atomique (retour Loki) — worker local', () => {
expect(matches).toEqual([0, 2]);
});
- test('expression invalide -> rejet', async () => {
- await expect(runEvalInWorker('obj.', { obj: { x: 1 } }, 5000)).rejects.toThrow();
+ test('expression invalide -> rejet (erreur vm du container)', async () => {
+ expect(() => evaluateInContainer('obj.', { obj: { x: 1 } }, 5000)).toThrow();
});
});
diff --git a/packages/engine/package.json b/packages/engine/package.json
index 596878545..3329a1450 100644
--- a/packages/engine/package.json
+++ b/packages/engine/package.json
@@ -1,6 +1,6 @@
{
"name": "@semantic-bus/engine",
- "version": "0.11.16",
+ "version": "0.11.17",
"description": "Processing engine module for Semantic Bus",
"private": true,
"main": "app.js",
@@ -56,7 +56,6 @@
"speakingurl": "^14.0.1",
"ssh2-sftp-client": "^12.0.1",
"superagent": "^10.2.1",
- "unicode-encode": "^1.4.2",
"webdriverio": "^9.4.0",
"xml2js": "^0.6.2"
},
diff --git a/packages/engine/utils/__mocks__/evalSecurity.js b/packages/engine/utils/__mocks__/evalSecurity.js
index 19e753905..af32c506b 100644
--- a/packages/engine/utils/__mocks__/evalSecurity.js
+++ b/packages/engine/utils/__mocks__/evalSecurity.js
@@ -1,18 +1,34 @@
'use strict';
// -----------------------------------------------------------------------------
-// Mock de evalSecurity pour les tests unitaires.
+// Mock de evalSecurity pour les tests unitaires engine.
//
-// En production, les évals sont exécutées dans le eval-service (container isolé,
-// via `runEvalInRemote` — appel HTTP signé, pas de fallback). Pour les tests
-// unitaires (sans container), on réutilise le worker_threads local
-// (`runEvalInWorker`) comme moteur de référence : même logique d'évaluation,
-// sans dépendance réseau.
+// En production, TOUTES les évaluations passent par le eval-service (container
+// isolé, `runEvalInRemote` — appel HTTP signé). L'engine n'a AUCUNE méthode
+// d'évaluation interne (plus de worker local). Pour les tests unitaires (sans
+// container), `runEvalInRemote` est mocké pour reproduire EXACTEMENT ce que le
+// container exécute : un contexte vm neuf construit par `secureContext.js`
+// (eval-service) + injection des variables + `vm.runInContext` avec timeout —
+// la même logique que `eval-service/evalWorker.js`.
// -----------------------------------------------------------------------------
+const vm = require('vm');
+const { createSecureContext } = require('../../../eval-service/secureContext.js');
const actual = jest.requireActual('../evalSecurity.js');
+function evaluateInContainer(expression, variables = {}, timeoutMs = 10000) {
+ const ctx = createSecureContext();
+ if (variables) Object.assign(ctx, variables);
+ return vm.runInContext(expression, ctx, { timeout: timeoutMs });
+}
+
module.exports = {
...actual,
- runEvalInRemote: actual.runEvalInWorker
-};
+ async runEvalInRemote(expression, variables = {}, timeoutMs = 10000) {
+ try {
+ return evaluateInContainer(expression, variables, timeoutMs);
+ } catch (e) {
+ throw new Error(e && e.message ? e.message : String(e));
+ }
+ }
+};
\ No newline at end of file
diff --git a/packages/engine/utils/evalSecurity.js b/packages/engine/utils/evalSecurity.js
index 913d716e1..d0a5937ee 100644
--- a/packages/engine/utils/evalSecurity.js
+++ b/packages/engine/utils/evalSecurity.js
@@ -145,64 +145,6 @@ function evalWithTimeout(fn, ms = 10000) {
});
}
-/**
- * Exécute `expression` (évaluée par eval) dans un worker_threads TERMINABLE,
- * avec un timeout strict (point 3).
- *
- * Contrairement à `evalWithTimeout`, un eval exécuté ici peut être réellement
- * interrompu : si le délai est dépassé, le worker est terminé via
- * `worker.terminate()` (un eval synchrone dans le thread principal bloquerait le
- * event loop et ne pourrait pas être interrompu par un simple setTimeout).
- *
- * En bonus, le code évalué tourne dans un thread isolé : il n'accède PAS aux
- * variables / modules du process principal (défense en profondeur).
- *
- * @param {string} expression code source à évaluer (scope master exposé dans le worker)
- * @param {Object} [scope] variables additionnelles exposées au code évalué (sécurisées)
- * @param {number} [timeoutMs=10000] délai maximal d'exécution
- * @returns {Promise<*>} le résultat du eval (structured clone via postMessage)
- */
-function runEvalInWorker(expression, scope = {}, timeoutMs = 10000) {
- return new Promise((resolve, reject) => {
- const worker = new Worker(path.join(__dirname, 'evalWorker.js'), {
- workerData: { expression, scope }
- });
-
- let settled = false;
- const settle = (fn, val) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- fn(val);
- };
-
- const timer = setTimeout(() => {
- worker.terminate();
- settle(reject, new Error(`Expression evaluation timed out after ${timeoutMs}ms`));
- }, timeoutMs);
-
- worker.on('message', (msg) => {
- if (msg && msg.ok === true) {
- settle(resolve, msg.result);
- } else {
- settle(reject, new Error((msg && msg.error) || 'Unknown eval error'));
- }
- // Worker jetable : on le ferme après le résultat (évite un handle ouvert,
- // qui ferait timeout `jest --detectOpenHandles` en CI).
- worker.terminate().catch(() => {});
- });
- worker.on('error', (err) => {
- settle(reject, err);
- worker.terminate().catch(() => {});
- });
- worker.on('exit', (code) => {
- if (code !== 0 && !settled) {
- settle(reject, new Error(`Eval worker exited with code ${code}`));
- }
- });
- });
-}
-
// Limites anti-ReDoS appliquées à l'opération RegExp (point 3) :
// longueur max du motif et de la chaîne d'entrée, timeout d'exécution.
const MAX_REGEX_PATTERN_LENGTH = 2048;
@@ -320,19 +262,34 @@ async function postEval(route, body, httpTimeoutMs, resultKey = 'result') {
* Évalue une expression dans le eval-service (container isolé).
* La résolution des valeurs ($/£) est faite AVANT (par l'appelant) : on envoie
* l'expression épurée + un objet `variables` séparé.
+ *
+ * SÉCURITÉ — POINT D'APPLICATION UNIQUE : c'est ici (AVANT `postEval`, donc
+ * AVANT le `JSON.stringify` du corps HTTP) que les `variables` sont assainies
+ * (`sanitizeValue`). Ceci est le SEUL endroit qui :
+ * 1. neutralise les getters/accessors : un getter s'exécute au moment du
+ * `JSON.stringify` DANS le process engine — le retirer avant empêche toute
+ * exécution de code au moment de la sérialisation ;
+ * 2. retire les clés dangereuses (`__proto__`/`constructor`/`prototype`) qui
+ * survivent au transport JSON et seraient réactivées côté récepteur par
+ * `Object.assign(ctx, variables)`.
+ * Tous les chemins d'éval (transformation, `$where`, ...) passent par ici :
+ * `$where` (filter.js, arraySplitByCondition.js) ne sanitize PAS par-caller.
+ * CONTRAT : le eval-service considère ses `variables` SÛRES à la seule condition
+ * qu'elles aient transité par `runEvalInRemote`. Ne pas contourner ce point.
+ *
* @param {string} expression expression JS (déjà résolue, sans {$..}/{£..})
* @param {Object} [variables] variables exposées dans le scope (résolues à l'extérieur)
* @param {number} [timeoutMs] timeout d'exécution côté service
* @returns {Promise<*>} résultat de l'évaluation
*/
async function runEvalInRemote(expression, variables = {}, timeoutMs = 10000) {
- return postEval('/eval', { expression, variables, timeoutMs }, EVAL_HTTP_TIMEOUT_MS);
+ const sanitizedVariables = sanitizeValue(variables);
+ return postEval('/eval', { expression, variables: sanitizedVariables, timeoutMs }, EVAL_HTTP_TIMEOUT_MS);
}
module.exports = {
sanitizeValue,
evalWithTimeout,
- runEvalInWorker,
runRegexInWorker,
runEvalInRemote,
DANGEROUS_KEYS,
diff --git a/packages/engine/utils/evalWorker.js b/packages/engine/utils/evalWorker.js
deleted file mode 100644
index 184abd704..000000000
--- a/packages/engine/utils/evalWorker.js
+++ /dev/null
@@ -1,136 +0,0 @@
-'use strict';
-
-// -----------------------------------------------------------------------------
-// evalWorker — worker_threads qui exécute une expression de transformation (eval)
-// dans un thread séparé et TERMINABLE (point 3 : timeout / DoS).
-//
-// Le thread principal bloque dans `runEvalInWorker` (Promise) et, si le délai est
-// dépassé, appelle `worker.terminate()` : le eval (boucle déguisée, ReDoS, ...)
-// ne peut plus bloquer indéfiniment le process engine.
-//
-// Le scope expose exactement les mêmes identifiants que le `eval` master de
-// objectTransformationV2 (libs par nom + helpers), pour une compatibilité 100%
-// des expressions de production. Les valeurs injectées (base64 via `unicode`) y
-// sont référencées par `this.resolveString(...)` / `this.parseAndResolveString(...)`.
-// -----------------------------------------------------------------------------
-
-const { parentPort, workerData } = require('worker_threads');
-const { stripDangerousGlobals } = require('./workerGlobals.js');
-
-const dayjs = require('dayjs-with-plugins');
-const he = require('he');
-const lodash = require('lodash');
-const removeMarkdown = require('remove-markdown');
-const sanitizeHtml = require('sanitize-html');
-const cheerio = require('cheerio');
-const moment = require('moment');
-const unicode = require('unicode-encode');
-const dotProp = require('dot-prop');
-const crypto = require('crypto');
-
-function decodeUnicode(str) {
- const regex = new RegExp('\\\\u([\\dA-Fa-f]{4})', 'g');
- return str.replace(regex, (m, g) => String.fromCharCode(parseInt(g, 16)));
-}
-
-function escapeString(source) {
- if (typeof source === 'string' || source instanceof String) {
- return `eval(this.unicode.atou(\`${unicode.utoa(source)}\`))`;
- } else if (Array.isArray(source)) {
- return source.map(r => escapeString(r));
- } else if (source != null && source.toJSON !== undefined) {
- return escapeString(source.toJSON());
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) {
- out[unicode.utoa(key)] = escapeString(source[key]);
- }
- return out;
- }
- return source;
-}
-
-function resolveString(source) {
- if (typeof source === 'string' || source instanceof String) {
- const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source);
- if (strict) {
- return unicode.atou(strict[1]);
- }
- return source;
- } else if (Array.isArray(source)) {
- return source.map(r => resolveString(r));
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) {
- out[unicode.atou(key)] = resolveString(source[key]);
- }
- return out;
- }
- return source;
-}
-
-function parseAndResolveString(source) {
- return resolveString(JSON.parse(source));
-}
-
-// Expose les libs + helpers sur le global : le eval master utilisait le scope
-// de module (variables libres) ET `this.xxx` (this = objet module, mais en mode
-// non-strict le `this` du eval pointe sur le global). On attache donc tout au
-// global pour que `this.resolveString(...)` et `dayjs(...)` fonctionnent.
-globalThis.dayjs = dayjs;
-globalThis.moment = moment;
-// lodash épuré : on retire template/templateSettings (compilent du code en host
-// realm, échappent au vm → RCE). Défense en profondeur (moteur de test).
-const LODASH_STRIPPED = ['template', 'templateSettings'];
-globalThis.lodash = Object.fromEntries(
- Object.entries(lodash).filter(([k]) => !LODASH_STRIPPED.includes(k))
-);
-globalThis.he = he;
-globalThis.removeMarkdown = removeMarkdown;
-globalThis.sanitizeHtml = sanitizeHtml;
-globalThis.cheerio = cheerio;
-globalThis.decodeUnicode = decodeUnicode;
-globalThis.dotProp = dotProp;
-globalThis.unicode = unicode;
-// Expose le module Node `crypto` (createHash, randomBytes, randomUUID, ...).
-// NB : globalThis.crypto est un accessor natif (WebCrypto) configurable ; on le
-// remplace par le module Node pour les expressions de prod qui utilisent
-// crypto.createHash(...).
-Object.defineProperty(globalThis, 'crypto', {
- value: crypto,
- writable: true,
- configurable: true,
- enumerable: true
-});
-globalThis.resolveString = resolveString;
-globalThis.escapeString = escapeString;
-globalThis.parseAndResolveString = parseAndResolveString;
-
-// Variables additionnelles passées par le thread principal (sécurisées).
-if (workerData && workerData.scope) {
- for (const key of Object.keys(workerData.scope)) {
- globalThis[key] = workerData.scope[key];
- }
-}
-
-// SÉCURITÉ : retire du global du worker les globals Node dangereux
-// (require/module/process/global/console) AVANT toute évaluation. Un `eval`
-// présent dans une expression utilisateur (ex. `eval('new '+... )` en prod) ne
-// peut donc PAS atteindre `require`/`process`/fs → pas de RCE par `eval` même
-// si `eval` est autorisé pour la compatibilité. `Buffer`/`crypto` restent.
-stripDangerousGlobals();
-
-let result;
-try {
- // Indirect eval : `this` pointe sur le global (où sont exposés les helpers /
- // libs), reproduisant le scope non-strict du eval master où `this.resolveString`
- // et `this.unicode` étaient accessibles. Les identifiants (dayjs, moment, ...)
- // sont exposés via globalThis.
- // eslint-disable-next-line no-eval
- result = (0, eval)(workerData.expression);
-} catch (e) {
- parentPort.postMessage({ ok: false, error: e && e.message ? e.message : String(e) });
- return;
-}
-
-parentPort.postMessage({ ok: true, result });
diff --git a/packages/engine/utils/objectTransformationV2.js b/packages/engine/utils/objectTransformationV2.js
index f3088f4a4..b9246c0ac 100644
--- a/packages/engine/utils/objectTransformationV2.js
+++ b/packages/engine/utils/objectTransformationV2.js
@@ -67,21 +67,12 @@ const loadCldrData = () => {
// Initialisation de Globalize avec toutes les locales
loadCldrData();
-function decodeUnicode(str) {
- // On définit le pattern : '\\\\u' pour \u et '([\\dA-Fa-f]{4})' pour les 4 chiffres hexadécimaux
- const regex = new RegExp('\\\\u([\\dA-Fa-f]{4})', 'g');
- return str.replace(regex, (match, grp) =>
- String.fromCharCode(parseInt(grp, 16))
- );
-}
-
module.exports = {
// Intl: require('intl'),
moment: require('moment'),
dotProp: require('dot-prop'),
- unicode: require('unicode-encode'),
executeWithParams: async function (source, pullParams, jsonTransformPattern, options, config) {
// console.log('config',config);
const out = await this.execute(source, pullParams, jsonTransformPattern, options, config);
@@ -164,50 +155,6 @@ module.exports = {
return jsonTransformPattern;
}
},
- escapeString(source) {
- if (typeof source === 'string' || source instanceof String) {
- return `eval(this.unicode.atou(\`${this.unicode.utoa(source)}\`))`;
- } else if (Array.isArray(source)) {
- return source.map(r => this.escapeString(r));
- } else if (source != null && source.toJSON !== undefined) {
- return this.escapeString(source.toJSON());
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) {
- out[this.unicode.utoa(key)] = this.escapeString(source[key]);
- }
- return out;
- } else {
- return source;
- }
- },
- parseAndResolveString(source) {
- return this.resolveString(JSON.parse(source));
- },
- resolveString(source) {
- if (typeof source === 'string' || source instanceof String) {
- // SÉCURITÉ (point 1) : on ne décode que la forme EXACTE produite par notre
- // `escapeString` : `eval(this.unicode.atou(\`...\`))`. Toute autre string
- // est retournée telle quelle sans eval, même si elle contient un `eval(...)`
- // — empêche `this.resolveString(donnéeUtilisateur)` de contourner le
- // validateur en exécutant du code embarqué dans une valeur.
- const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source);
- if (strict) {
- return this.unicode.atou(strict[1]);
- }
- return source;
- } else if (Array.isArray(source)) {
- return source.map(r => this.resolveString(r));
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) {
- out[this.unicode.atou(key)] = this.resolveString(source[key]);
- }
- return out;
- } else {
- return source;
- }
- },
getValueFromSource(source, pullParams, pattern) {
if (pattern.localeCompare('$..') == 0 || pattern.localeCompare('$') == 0) {
return source;
diff --git a/packages/engine/utils/validateExpression.js b/packages/engine/utils/validateExpression.js
index 38ad16960..8be9a3511 100644
--- a/packages/engine/utils/validateExpression.js
+++ b/packages/engine/utils/validateExpression.js
@@ -63,7 +63,9 @@ const FORBIDDEN_PROPERTIES = new Set([
// blacklist (retirer une liste) : toute nouvelle méthode doit être ajoutée ici,
// ce qui passe par une revue (PR) et garantit qu'aucune fonction compilant du
// code host-realm (comme lodash.template) ne peut être appelée.
-// Ces whitelists doivent rester COHÉRENTES avec secureContext.js (eval-service).
+// Ces whitelists doivent rester COHÉRENTES avec secureContext.js (eval-service) :
+// TOUTE lib exposée dans le scope eval (makeHelpers) DOIT figurer ici, sinon ses
+// méthodes échappent au contrôle (voir SB-RCE-2026-01, review 2026-08-24).
// -----------------------------------------------------------------------------
const LIB_METHOD_WHITELISTS = {
// Alias lodash/underscore
@@ -85,16 +87,203 @@ const LIB_METHOD_WHITELISTS = {
dayjs: new Set(['unix', 'utc', 'locale', 'isDayjs', 'duration', 'max', 'min']),
moment: new Set(['utc', 'unix', 'locale', 'duration', 'min', 'max', 'now', 'isMoment']),
Buffer: new Set(['from']),
- crypto: new Set(['createHash', 'randomUUID', 'randomBytes'])
+ crypto: new Set(['createHash', 'randomUUID', 'randomBytes']),
+ // --- Libs/helpers exposés par makeHelpers (secureContext.js) mais hors whitelist
+ // --- auparavant (SB-RCE-2026-01, point 2 du chercheur). Cohérence validateur ↔ scope.
+ cheerio: new Set(['load']), // seul point d'entrée de parse DOM (pattern prod)
+ dotProp: new Set(['get', 'has', 'delete']), // `set` bloqué (proto-pollution, via whitelist)
+ sanitizeHtml: new Set(), // appel nu uniquement
+ removeMarkdown: new Set(), // appel nu uniquement
+ decodeUnicode: new Set(), // appel nu uniquement
+ // ---------------------------------------------------------------------------
+ // JS INTRINSICS GLOBAUX — WHITELIST STRICTE (plus aucun "par défaut autorisé").
+ // Les built-ins d'introspection (Reflect, Proxy, Object.getPrototypeOf,
+ // Object.getOwnPropertyDescriptor, ...) sont ABSENTS ou en whitelist VIDE :
+ // tout accès membre est bloqué. C'est ce qui neutralise le vecteur
+ // "nom de propriété dangereux passé en ARGUMENT string" (SB-RCE-2026-01,
+ // au-delà des points du chercheur) : Reflect.get(he.decode,'constructor')
+ // est désormais rejeté.
+ // ---------------------------------------------------------------------------
+ Object: new Set(['keys', 'values', 'entries', 'assign', 'create', 'fromEntries', 'is', 'hasOwn']),
+ Math: new Set([
+ 'round', 'floor', 'ceil', 'abs', 'max', 'min', 'pow', 'sqrt', 'cbrt', 'trunc',
+ 'sign', 'random', 'hypot', 'log', 'log2', 'log10', 'log1p', 'exp', 'expm1',
+ 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2', 'sinh', 'cosh', 'tanh',
+ 'asinh', 'acosh', 'atanh', 'clamp', 'fround', 'imul',
+ 'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2'
+ ]),
+ JSON: new Set(['parse', 'stringify']),
+ Array: new Set(['isArray', 'from', 'of']),
+ String: new Set(['fromCharCode', 'fromCodePoint', 'raw']),
+ Number: new Set([
+ 'isInteger', 'isFinite', 'isNaN', 'isSafeInteger', 'parseFloat', 'parseInt',
+ 'MAX_SAFE_INTEGER', 'MIN_SAFE_INTEGER', 'MAX_VALUE', 'MIN_VALUE', 'EPSILON',
+ 'POSITIVE_INFINITY', 'NEGATIVE_INFINITY', 'NaN'
+ ]),
+ Date: new Set(['now', 'parse', 'UTC']),
+ Symbol: new Set(['for', 'keyFor']),
+ // Utilisables via `new` (DEFAULT_NEW_WHITELIST) ou appel nu ; membres statiques
+ // TOUS bloqués par défaut (whitelist vide).
+ Boolean: new Set(),
+ BigInt: new Set(),
+ RegExp: new Set(),
+ Map: new Set(),
+ Set: new Set(),
+ WeakMap: new Set(),
+ WeakSet: new Set(),
+ ArrayBuffer: new Set(),
+ SharedArrayBuffer: new Set(),
+ DataView: new Set(),
+ Atomics: new Set(),
+ Promise: new Set(),
+ Error: new Set(),
+ AggregateError: new Set(),
+ EvalError: new Set(),
+ RangeError: new Set(),
+ ReferenceError: new Set(),
+ SyntaxError: new Set(),
+ TypeError: new Set(),
+ URIError: new Set(),
+ Int8Array: new Set(),
+ Uint8Array: new Set(),
+ Uint8ClampedArray: new Set(),
+ Int16Array: new Set(),
+ Uint16Array: new Set(),
+ Int32Array: new Set(),
+ Uint32Array: new Set(),
+ Float32Array: new Set(),
+ Float64Array: new Set(),
+ BigInt64Array: new Set(),
+ BigUint64Array: new Set(),
+ Intl: new Set(),
+ FinalizationRegistry: new Set(),
+ WeakRef: new Set(),
+ Reflect: new Set(), // introspection -> TOUT bloqué
+ Proxy: new Set() // constructeur d'interposition -> TOUT bloqué
+};
+
+// -----------------------------------------------------------------------------
+// WHITELIST des MÉTHODES sur les OBJETS PRODUITS par les libs exposées.
+// Une lib autorisée ne donne accès qu'aux méthodes de ses objets produits qui
+// figurent explicitement ici (stratégie 100% WHITELIST — plus aucune blacklist).
+// Le type de l'objet produit est inféré statiquement de la chaîne d'appels
+// (voir exprType). Les résultats de type "data" (strings/tableaux/objets plats
+// produits par he.decode, lodash.*, les variables du flux, ...) ne sont PAS
+// restreints ici : leurs méthodes natives ne sont pas des surfaces d'API de lib,
+// et le seul vecteur d'évasion (constructor/__proto__/prototype) est bloqué par
+// FORBIDDEN_PROPERTIES + le constant-folding.
+// -----------------------------------------------------------------------------
+const PRODUCED_WHITELISTS = {
+ dayjsInstance: new Set([
+ 'format', 'add', 'subtract', 'diff', 'startOf', 'endOf', 'get', 'set', 'unix',
+ 'valueOf', 'toISOString', 'toDate', 'toJSON', 'toArray', 'toString', 'isBefore',
+ 'isAfter', 'isSame', 'isSameOrBefore', 'isSameOrAfter', 'isValid', 'isDayjs',
+ 'year', 'month', 'date', 'day', 'dayOfYear', 'week', 'isoWeek', 'hour', 'minute',
+ 'second', 'millisecond', 'daysInMonth', 'utcOffset', 'local', 'utc', 'clone'
+ ]),
+ momentInstance: new Set([
+ 'format', 'add', 'subtract', 'diff', 'startOf', 'endOf', 'get', 'set', 'unix',
+ 'valueOf', 'toISOString', 'toDate', 'toJSON', 'toString', 'isBefore', 'isAfter',
+ 'isSame', 'isSameOrBefore', 'isSameOrAfter', 'isValid', 'isMoment', 'year',
+ 'month', 'date', 'day', 'dayOfYear', 'week', 'isoWeek', 'hour', 'minute',
+ 'second', 'millisecond', 'daysInMonth', 'utcOffset', 'local', 'utc', 'clone',
+ 'fromNow', 'calendar'
+ ]),
+ cheerioInstance: new Set([
+ 'text', 'html', 'map', 'get', 'each', 'find', 'filter', 'first', 'last', 'eq',
+ 'attr', 'removeAttr', 'addClass', 'removeClass', 'hasClass', 'prop', 'removeProp',
+ 'val', 'data', 'removeData', 'next', 'nextAll', 'prev', 'prevAll', 'parent',
+ 'parents', 'parentsUntil', 'closest', 'children', 'contents', 'siblings',
+ 'toArray', 'serialize', 'serializeArray', 'is', 'not', 'has', 'add', 'slice',
+ 'end', 'append', 'prepend', 'after', 'before', 'remove', 'empty', 'clone',
+ 'wrap', 'unwrap', 'css', 'replaceWith', 'length'
+ ]),
+ // `cheerio.load(...)` retourne la fonction de sélection (`$`) ; ses méthodes
+ // et l'instance produite partagent la même surface d'API.
+ cheerioCallable: new Set([
+ 'text', 'html', 'map', 'get', 'each', 'find', 'filter', 'first', 'last', 'eq',
+ 'attr', 'prop', 'val', 'data', 'toArray', 'serialize', 'is', 'not', 'has',
+ 'add', 'slice', 'end', 'clone', 'length'
+ ]),
+ hash: new Set(['update', 'digest']),
+ bufferResult: new Set(['toString'])
};
-// Méthodes statiques dont l'accès est TOUJOURS interdit sur les libs exposées
-// (compilation de code / proto-pollution / évasion) — blacklist complémentaire.
-const LIB_FORBIDDEN_METHODS = new Set([
- 'template', 'templateSettings',
- 'merge', 'mergeWith', 'defaultsDeep', 'set', 'setWith', 'assign', 'defaults',
- 'update', 'updateWith', 'zipObjectDeep', 'transform', 'create'
+
+// Méthodes statiques d'une lib dont l'appel PRODUIT une instance (ex. dayjs.utc).
+const DATE_STATIC_PRODUCING = new Set(['utc', 'unix']);
+// Méthodes d'une instance date qui renvoient une instance du même type (chaînage).
+const DATE_INSTANCE_PRODUCING = new Set(['add', 'subtract', 'startOf', 'endOf', 'set', 'utc', 'local', 'clone']);
+// Méthodes d'un objet cheerio qui renvoient un objet cheerio (chaînage).
+const CHEERIO_INSTANCE_PRODUCING = new Set([
+ 'map', 'find', 'filter', 'first', 'last', 'eq', 'slice', 'add', 'not', 'has',
+ 'end', 'parent', 'parents', 'closest', 'children', 'siblings', 'next', 'prev',
+ 'nextAll', 'prevAll', 'clone', 'append', 'prepend', 'after', 'before', 'wrap', 'unwrap'
]);
+/**
+ * Infère statiquement le type d'une expression (mini type-system dédié au
+ * validateur), pour appliquer PRODUCED_WHITELISTS aux objets produits par les
+ * libs exposées. Types : libs ('dayjs', 'he', ...), objets produits
+ * ('dayjsInstance', 'cheerioInstance', 'hash', 'bufferResult', ...), 'this',
+ * 'data' (données/variables, non restreint) ou `undefined` (inconnu).
+ */
+function exprType(node) {
+ if (!node) return undefined;
+ switch (node.type) {
+ case 'Identifier': {
+ if (LIB_METHOD_WHITELISTS[node.name] !== undefined) return node.name;
+ return undefined; // variable du flux (v0, obj, source, key, ...) : non restreinte
+ }
+ case 'ThisExpression':
+ return 'this';
+ case 'Literal':
+ return 'data';
+ case 'CallExpression': {
+ const calleeType = exprType(node.callee);
+ switch (calleeType) {
+ case 'dayjs': return 'dayjsInstance';
+ case 'moment': return 'momentInstance';
+ case 'cheerioLoad': return 'cheerioCallable';
+ case 'cheerioCallable':
+ case 'cheerioInstance':
+ return 'cheerioInstance';
+ case 'dayjsInstance':
+ case 'momentInstance':
+ case 'hash':
+ case 'bufferResult':
+ return calleeType;
+ default:
+ return 'data';
+ }
+ }
+ case 'MemberExpression': {
+ const objType = exprType(node.object);
+ const meth = node.computed ? foldStaticValue(node.property) : node.property && node.property.name;
+ // `this.moment` / `this.dayjs` / ... : membre d'accès sur `this` = la lib.
+ if (objType === 'this' && typeof meth === 'string' && LIB_METHOD_WHITELISTS[meth] !== undefined) {
+ return meth;
+ }
+ if (typeof meth !== 'string') return 'data';
+ if (objType === 'dayjs' || objType === 'moment') {
+ return DATE_STATIC_PRODUCING.has(meth) ? (objType === 'dayjs' ? 'dayjsInstance' : 'momentInstance') : 'data';
+ }
+ if (objType === 'cheerio') return meth === 'load' ? 'cheerioLoad' : 'data';
+ if (objType === 'crypto') return meth === 'createHash' ? 'hash' : 'data';
+ if (objType === 'Buffer') return meth === 'from' ? 'bufferResult' : 'data';
+ if (objType === 'dayjsInstance' || objType === 'momentInstance') {
+ return DATE_INSTANCE_PRODUCING.has(meth) ? objType : 'data';
+ }
+ if (objType === 'cheerioInstance' || objType === 'cheerioCallable' || objType === 'cheerioLoad') {
+ return CHEERIO_INSTANCE_PRODUCING.has(meth) ? 'cheerioInstance' : 'data';
+ }
+ if (objType === 'hash') return meth === 'update' ? 'hash' : 'data';
+ return 'data';
+ }
+ default:
+ return 'data';
+ }
+}
+
// Constructeurs autorisés pour `new` par défaut (sûrs).
const DEFAULT_NEW_WHITELIST = new Set([
'Array', 'BigInt', 'Boolean', 'Date', 'Error', 'Map', 'Number', 'Object',
@@ -143,7 +332,9 @@ function getReceptorLib(node) {
if (cur && cur.type === 'Identifier') {
const name = cur.name;
if (name === '_' || name === 'underscore') return 'lodash';
- if (LIB_METHOD_WHITELISTS[name]) return name;
+ // Toute lib exposée OU tout JS intrinsic global : si whitelist absente/vide
+ // (Reflect, Proxy, introspection...), tout accès membre sera bloqué.
+ if (LIB_METHOD_WHITELISTS[name] !== undefined) return name;
}
return null;
}
@@ -153,6 +344,47 @@ function isLodashReceptor(node) {
return getReceptorLib(node) === 'lodash';
}
+/**
+ * Constant-folding d'une clé computed : résout statiquement une expression de
+ * clé composée uniquement de constantes (littéraux, concaténations, templates
+ * sans interpolation). Retourne `undefined` si la clé n'est pas statiquement
+ * résolvable (variable dynamique, appel, ...).
+ *
+ * Objectif sécurité : une clé computed non-littérale comme `'con'+'structor'`
+ * (BinaryExpression) n'a pas de `.value`, ce qui faisait sauter les gardes
+ * `prop !== undefined` (FORBIDDEN_PROPERTIES / whitelist / blacklist) — voir
+ * SB-RCE-2026-01 (review chercheur 2026-08-24). En repliant la clé, le résultat
+ * (`'constructor'`) passe dans les mêmes contrôles que les clés littérales.
+ *
+ * Le cas non résolu (clé dynamique, ex. `obj[key]`) reste autorisé : il n'est
+ * fermable que par un garde runtime (contenu par l'isolation du worker).
+ */
+function foldStaticValue(node) {
+ if (!node) return undefined;
+ if (node.type === 'Literal') return node.value;
+ if (node.type === 'TemplateLiteral') {
+ // Replie les templates dont toutes les interpolations sont statiquement
+ // résolubles : `con${''}structor` -> 'constructor'. Si une interpolation
+ // est dynamique (variable), on ne peut pas replier.
+ const parts = [];
+ for (let i = 0; i < node.quasis.length; i++) {
+ parts.push(node.quasis[i].value.cooked ?? '');
+ if (i < node.expressions.length) {
+ const v = foldStaticValue(node.expressions[i]);
+ if (v === undefined) return undefined;
+ parts.push(String(v));
+ }
+ }
+ return parts.join('');
+ }
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
+ const left = foldStaticValue(node.left);
+ const right = foldStaticValue(node.right);
+ if (left !== undefined && right !== undefined) return String(left) + String(right);
+ }
+ return undefined;
+}
+
/**
* Valide une expression JS avant évaluation. Lève ExpressionValidationError si
* le code est inacceptable.
@@ -185,7 +417,10 @@ function validateExpression(source, options = {}) {
// 2. Membre : propriété interdite (y compris via notation calcée)
if (node.type === 'MemberExpression') {
- const prop = node.computed ? node.property.value : node.property && node.property.name;
+ // Constant-folding des clés computed statiquement résolubles : une clé
+ // non-littérale (`'con'+'structor'`) est repliée vers sa valeur afin de
+ // passer par les mêmes contrôles que les clés littérales.
+ const prop = node.computed ? foldStaticValue(node.property) : node.property && node.property.name;
if (prop !== undefined && FORBIDDEN_PROPERTIES.has(prop)) {
throw new ExpressionValidationError(`Forbidden property access: ${prop}`);
}
@@ -203,14 +438,17 @@ function validateExpression(source, options = {}) {
const lib = getReceptorLib(node.object);
if (lib && prop !== undefined && typeof prop === 'string') {
const whitelist = LIB_METHOD_WHITELISTS[lib];
- if (whitelist && !whitelist.has(prop)) {
+ if (!whitelist || !whitelist.has(prop)) {
throw new ExpressionValidationError(`Forbidden method on ${lib}: ${prop}`);
}
}
- // Blacklist complémentaire : méthodes toujours interdites (compilation de
- // code host-realm / proto-pollution) sur toute lib exposée.
- if (lib && prop !== undefined && LIB_FORBIDDEN_METHODS.has(prop)) {
- throw new ExpressionValidationError(`Forbidden method on ${lib}: ${prop}`);
+ // WHITELIST des objets PRODUITS par les libs exposées (ex. dayjs(x).format,
+ // cheerio.load(x)(sel).text(), crypto.createHash().digest()). Le type du
+ // récepteur est inféré statiquement ; toute méthode non listée est interdite.
+ const producedType = exprType(node.object);
+ const producedWhitelist = PRODUCED_WHITELISTS[producedType];
+ if (producedWhitelist && prop !== undefined && typeof prop === 'string' && !producedWhitelist.has(prop)) {
+ throw new ExpressionValidationError(`Forbidden method on ${producedType}: ${prop}`);
}
}
@@ -282,15 +520,13 @@ function validateExpression(source, options = {}) {
// La méthode doit figurer dans la whitelist de la lib. Couvre à la fois
// la proto-pollution (lodash.merge/_.set), la compilation de code
// host-realm (lodash.template) et toute autre méthode non autorisée.
+ // (Les méthodes sur objets produits sont contrôlées au niveau du
+ // MemberExpression — voir PRODUCED_WHITELISTS.)
const lib = getReceptorLib(node.callee.object);
if (lib) {
const whitelist = LIB_METHOD_WHITELISTS[lib];
const meth = node.callee.property.name;
- if (whitelist && !whitelist.has(meth)) {
- throw new ExpressionValidationError(`Forbidden method call on ${lib}: ${meth}`);
- }
- // Blacklist complémentaire (même si whitelist undefined — alias).
- if (LIB_FORBIDDEN_METHODS.has(meth)) {
+ if (!whitelist || !whitelist.has(meth)) {
throw new ExpressionValidationError(`Forbidden method call on ${lib}: ${meth}`);
}
}
diff --git a/packages/engine/utils/workerGlobals.js b/packages/engine/utils/workerGlobals.js
deleted file mode 100644
index ca90bd73b..000000000
--- a/packages/engine/utils/workerGlobals.js
+++ /dev/null
@@ -1,40 +0,0 @@
-'use strict';
-
-// -----------------------------------------------------------------------------
-// workerGlobals — nettoyage du scope global d'un worker_threads avant toute
-// évaluation de code utilisateur (eval / $where).
-//
-// Un worker_threads expose sur `globalThis` des globals Node dangereux pour une
-// évaluation de code arbitraire : `require`, `module`, `process`, `global`,
-// `console`. Sans nettoyage, un `eval('require("fs")...')` dans l'expression d'un
-// utilisateur accéderait au système (RCE). On les RETIRE donc du global du
-// worker : le `eval` interne ne peut alors plus atteindre `require`/`process`/fs.
-//
-// `Buffer` est conservé (usage de production sûr : Buffer.from(...).toString()).
-// `crypto` est exposé séparément (référence maîtrisée) par evalWorker.js.
-// -----------------------------------------------------------------------------
-
-const DANGEROUS_GLOBALS = ['require', 'module', 'process', 'global', 'console'];
-
-/**
- * Retire les globals Node dangereux du `globalThis` du worker courant.
- * Idempotent. À appeler AVANT toute évaluation de code utilisateur.
- */
-function stripDangerousGlobals() {
- for (const name of DANGEROUS_GLOBALS) {
- try {
- // configurable pour process/global ; delete sur require/module peut échouer
- // silencieusement selon la version de Node, d'où le try/catch.
- delete globalThis[name];
- } catch (e) {
- // ignore : si la propriété n'est pas supprimable, on tente une redéfinition
- try {
- Object.defineProperty(globalThis, name, { value: undefined, writable: false, configurable: false });
- } catch (e2) {
- // dernier recours : rien (le validateur + le scope maîtrisé restent)
- }
- }
- }
-}
-
-module.exports = { stripDangerousGlobals, DANGEROUS_GLOBALS };
diff --git a/packages/eval-service/evalWorker.js b/packages/eval-service/evalWorker.js
index e60870906..56981fc48 100644
--- a/packages/eval-service/evalWorker.js
+++ b/packages/eval-service/evalWorker.js
@@ -18,51 +18,8 @@
const { parentPort } = require('worker_threads');
const vm = require('vm');
-const unicode = require('unicode-encode');
const { createSecureContext, stripDangerousGlobals } = require('./secureContext.js');
-// Helpers compatibles avec le scope master (utilisés par certaines expressions).
-function resolveString(source) {
- if (typeof source === 'string' || source instanceof String) {
- const strict = /^eval\(this\.unicode\.atou\(`([^`]*)`\)\)$/.exec(source);
- if (strict) return unicode.atou(strict[1]);
- return source;
- } else if (Array.isArray(source)) {
- return source.map((r) => resolveString(r));
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) out[unicode.atou(key)] = resolveString(source[key]);
- return out;
- }
- return source;
-}
-function escapeString(source) {
- if (typeof source === 'string' || source instanceof String) {
- return `eval(this.unicode.atou(\`${unicode.utoa(source)}\`))`;
- } else if (Array.isArray(source)) {
- return source.map((r) => escapeString(r));
- } else if (source != null && source.toJSON !== undefined) {
- return escapeString(source.toJSON());
- } else if (source != null && typeof source === 'object') {
- const out = {};
- for (const key in source) out[unicode.utoa(key)] = escapeString(source[key]);
- return out;
- }
- return source;
-}
-function parseAndResolveString(source) {
- return resolveString(JSON.parse(source));
-}
-
-// Helpers locaux (spécifiques à l'éval d'une expression) injectés EN PLUS du
-// contexte sécurisé fourni par secureContext.js.
-const localHelpers = {
- unicode,
- resolveString,
- escapeString,
- parseAndResolveString
-};
-
stripDangerousGlobals();
parentPort.on('message', (msg) => {
@@ -71,7 +28,14 @@ parentPort.on('message', (msg) => {
// Contexte vm NEUF et SÉCURISÉ (libs épurées/gelées + import bloqué) par job.
const ctx = createSecureContext();
- Object.assign(ctx, localHelpers);
+ // CONTRAT DE SÉCURITÉ : les `variables` injectées dans le scope sont considérées
+ // SÛRES à la SEULE condition qu'elles aient transité par `runEvalInRemote`
+ // (engine), qui applique `sanitizeValue` (retrait des clés __proto__/constructor/
+ // prototype + des accessors/getters) avant la sérialisation HTTP — c'est le point
+ // d'application unique côté engine (voir evalSecurity.js). Ce container est INTERNE
+ // (non exposé, signé HMAC, appelable uniquement par l'application) : ne pas appeler
+ // /eval avec des variables non assainies et ne pas exposer ce port hors du réseau
+ // interne. Tout nouveau chemin d'entrée DOIT passer par runEvalInRemote.
if (variables) Object.assign(ctx, variables);
try {
diff --git a/packages/eval-service/package.json b/packages/eval-service/package.json
index 84232c543..d5a6fdf15 100644
--- a/packages/eval-service/package.json
+++ b/packages/eval-service/package.json
@@ -1,6 +1,6 @@
{
"name": "@semantic-bus/eval-service",
- "version": "0.11.16",
+ "version": "0.11.17",
"description": "Service d'évaluation JavaScript isolé en container (dédié aux eval des transformations / $where).",
"private": true,
"main": "app.js",
@@ -18,7 +18,6 @@
"lodash": "^4.17.21",
"moment": "^2.30.1",
"remove-markdown": "^0.6.2",
- "sanitize-html": "^2.17.0",
- "unicode-encode": "^1.4.2"
+ "sanitize-html": "^2.17.0"
}
}
diff --git a/packages/eval-service/secureContext.js b/packages/eval-service/secureContext.js
index 6c8a4db94..ba8d38b6a 100644
--- a/packages/eval-service/secureContext.js
+++ b/packages/eval-service/secureContext.js
@@ -24,7 +24,6 @@ const removeMarkdown = require('remove-markdown');
const sanitizeHtml = require('sanitize-html');
const cheerio = require('cheerio');
const moment = require('moment');
-const unicode = require('unicode-encode');
const dotProp = require('dot-prop');
const nodeCrypto = require('crypto');
const { stripDangerousGlobals } = require('./workerGlobals.js');
@@ -153,7 +152,6 @@ function makeHelpers() {
cheerio,
decodeUnicode,
dotProp,
- unicode,
crypto: safeCrypto, // wrapper minimal (createHash/randomUUID)
Buffer: safeBuffer // wrapper minimal (from uniquement)
};
@@ -173,6 +171,10 @@ const helpers = makeHelpers();
// Crée un contexte vm NEUF et SÉCURISÉ, pré-rempli avec les libs épurées.
// Les variables additionnelles (résolues à l'extérieur) sont injectées par
// l'appelant. Toute import() dynamique depuis le contexte est rejeté.
+// CONTRAT DE SÉCURITÉ : les variables injectées dans le contexte sont considérées
+// sûres à la seule condition d'avoir transité par `runEvalInRemote` (engine), qui
+// applique `sanitizeValue` avant sérialisation. Ne pas alimenter ce contexte avec
+// des variables non assainies.
function createSecureContext() {
const ctx = vm.createContext({}, {
importModuleDynamically: () =>
diff --git a/packages/main/package.json b/packages/main/package.json
index e10e01ee0..c4d070a3b 100644
--- a/packages/main/package.json
+++ b/packages/main/package.json
@@ -1,6 +1,6 @@
{
"name": "@semantic-bus/main",
- "version": "0.11.16",
+ "version": "0.11.17",
"description": "Main application module for Semantic Bus",
"private": true,
"main": "app.js",
diff --git a/packages/timer/package.json b/packages/timer/package.json
index a11d32944..e8d06e7f9 100644
--- a/packages/timer/package.json
+++ b/packages/timer/package.json
@@ -1,6 +1,6 @@
{
"name": "@semantic-bus/timer",
- "version": "0.11.16",
+ "version": "0.11.17",
"description": "Timer scheduler module for Semantic Bus",
"private": true,
"main": "app.js",