From 613acb834fd0710b87a9814914ef07102b4a61b2 Mon Sep 17 00:00:00 2001 From: Sebastien Larinier Date: Thu, 14 May 2026 14:37:54 +0200 Subject: [PATCH 1/3] review: fix CSP issues, XSS hardening, init race and i18n stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal: replace inline onclick/onerror by addEventListener, escape all technique/page-derived strings via escapeHtml() before innerHTML, harden the gradient color with sanitizeHexColor(). UI button: drop the buttonCreated early-return that prevented re-creation after deletion; build the button via DOM nodes instead of innerHTML. content.js: declare all @keyframes (fadeIn, slideIn, slideInRight) at document-level so the suspicious-site alert and details modal get their animation. Wait for suspiciousSitesManager in checkDependencies so the suspicious-site check is never missed on first page load. contentExtractor: switch cleanText to Unicode property escapes (\p{L}\p{N}\p{M}) — the previous ASCII \w stripped Cyrillic / Chinese / Arabic content, which is exactly what the plugin is meant to analyse. Tighten detectPageType with word/stem boundaries and parse the URL so "businessnews.com" no longer triggers the "news" branch. Suspicioussitesmanager: gate all the verbose console.log calls behind a window.DIMA_DEBUG flag — the extension runs on and was spamming every host page's console. keywords: drop the duplicated alternative `(cette|cette|this)` in the TE0153 pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- content.js | 27 +++- data/keywords.js | 2 +- modules/Suspicioussitesmanager.js | 71 ++++---- modules/contentExtractor.js | 71 +++++--- modules/uiManager.js | 260 ++++++++++++++++++------------ 5 files changed, 268 insertions(+), 163 deletions(-) diff --git a/content.js b/content.js index 2ecd90b..1060540 100644 --- a/content.js +++ b/content.js @@ -102,14 +102,28 @@ class DIMAAnalyzer { // ===== INITIALISATION ET STYLES ===== -// CSS pour les animations +// CSS pour les animations (tous les @keyframes utilisés par l'extension +// sont définis ici une seule fois pour qu'ils soient disponibles avant +// l'apparition du bouton, de l'alerte de site suspect ou du modal). const style = document.createElement("style"); style.textContent = ` @keyframes dimaFadeIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } - + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideIn { + from { transform: translateY(30px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes slideInRight { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + #dima-btn { animation: dimaFadeIn 0.5s ease-out; } @@ -119,7 +133,11 @@ document.head.appendChild(style); // Initialisation sécurisée avec gestion d'erreurs améliorée console.log("DIMA: Script chargé - Version complète avec mots-clés améliorés"); -// Vérifier que toutes les dépendances sont chargées +// Vérifier que toutes les dépendances sont chargées. +// suspiciousSitesManager s'initialise via un setTimeout dans son module, +// donc on l'attend ici aussi pour éviter une race où le bouton est créé +// avant que la base de sites suspects soit prête (l'alerte ne s'affichait +// alors pas sur la toute première page consultée). function checkDependencies() { return ( window.DIMA_TECHNIQUES && @@ -127,7 +145,8 @@ function checkDependencies() { window.CONTEXT_PATTERNS && window.ContentExtractor && window.TechniqueAnalyzer && - window.UIManager + window.UIManager && + typeof window.checkSuspiciousSite === "function" ); } diff --git a/data/keywords.js b/data/keywords.js index cacfc4a..bc08842 100644 --- a/data/keywords.js +++ b/data/keywords.js @@ -53,7 +53,7 @@ const DIMA_ENHANCED_KEYWORDS = { patterns: [ /\d+\s+(?:choses|façons|méthodes|secrets|things|ways|methods)\s+(?:que|pour|de|to|that)/i, /(?:voici|découvrez|here's|discover)\s+(?:comment|pourquoi|ce que|how|why|what)/i, - /(?:cette|cette|this)\s+\w+\s+va\s+vous\s+(?:\w+|will)/i, + /(?:cette|this)\s+\w+\s+va\s+vous\s+(?:\w+|will)/i, /(?:shocking|amazing|incredible)\s+(?:secret|truth|fact)/i, ], }, diff --git a/modules/Suspicioussitesmanager.js b/modules/Suspicioussitesmanager.js index 7dd5176..569e0d2 100644 --- a/modules/Suspicioussitesmanager.js +++ b/modules/Suspicioussitesmanager.js @@ -19,28 +19,35 @@ class SuspiciousSitesManager { byTags: {}, bySocialPlatform: {} }; - + // Logs verbeux désactivés par défaut: l'extension s'exécute sur + // et inondait la console de chaque page hôte. + this.debug = !!(typeof window !== 'undefined' && window.DIMA_DEBUG); + this.init(); } + log(...args) { + if (this.debug) console.log(...args); + } + /** * Initialise le gestionnaire en chargeant toutes les sources disponibles */ init() { - console.log('🛡️ DIMA: Initialisation du gestionnaire de sites suspects...'); - + this.log('🛡️ DIMA: Initialisation du gestionnaire de sites suspects...'); + // Détecter et charger les sources disponibles this.detectAndLoadSources(); - + // Agréger tous les sites this.aggregateAllSites(); - + // Calculer les statistiques this.calculateStats(); - - console.log(`✅ DIMA: ${this.allSites.length} entrées chargées depuis ${this.sources.size} source(s)`); - console.log(` - ${this.stats.totalDomains} domaines`); - console.log(` - ${this.stats.totalSocialAccounts} comptes de réseaux sociaux`); + + this.log(`✅ DIMA: ${this.allSites.length} entrées chargées depuis ${this.sources.size} source(s)`); + this.log(` - ${this.stats.totalDomains} domaines`); + this.log(` - ${this.stats.totalSocialAccounts} comptes de réseaux sociaux`); this.logStats(); } @@ -57,7 +64,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.recordedfuture.com/research/cta-ru-2025-0917', reportDate: '2025-09-17' }); - console.log(` ✓ Source CopyCop chargée: ${copycopDomains.length} domaines`); + this.log(` ✓ Source CopyCop chargée: ${copycopDomains.length} domaines`); } // Source 2: RRN (VIGINUM) @@ -69,7 +76,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.sgdsn.gouv.fr/files/files/20230619_NP_VIGINUM_RAPPORT-CAMPAGNE-RRN_VF_0.pdf', reportDate: '2023-06-19' }); - console.log(` ✓ Source RRN chargée: ${rrnDomains.length} domaines`); + this.log(` ✓ Source RRN chargée: ${rrnDomains.length} domaines`); } // Source 3: Portal Kombat (VIGINUM) @@ -81,7 +88,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.sgdsn.gouv.fr/files/files/20240212_NP_SGDSN_VIGINUM_RAPPORT-RESEAU-PORTAL-KOMBAT_VF.pdf', reportDate: '2024-02-01' }); - console.log(` ✓ Source Portal Kombat chargée: ${portalKombatDomains.length} domaines`); + this.log(` ✓ Source Portal Kombat chargée: ${portalKombatDomains.length} domaines`); } // Source 4: Baybridge (IRSEM) @@ -93,7 +100,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.irsem.fr/focus', reportDate: '2025-10-17' }); - console.log(` ✓ Source Baybridge chargée: ${baybridgeDomains.length} domaines`); + this.log(` ✓ Source Baybridge chargée: ${baybridgeDomains.length} domaines`); } // Source 5: Storm 1516 - Domaines (VIGINUM) @@ -105,7 +112,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.defense.gouv.fr/sites/default/files/desinformation/Rapport%20Storm%201516%20-%20SGDSN.pdf', reportDate: '2025-05-02' }); - console.log(` ✓ Source Storm 1516 (domaines) chargée: ${storm1516Domains.length} domaines`); + this.log(` ✓ Source Storm 1516 (domaines) chargée: ${storm1516Domains.length} domaines`); } // Source 6: Storm 1516 - Comptes sociaux (VIGINUM) - FORMAT NATIF @@ -117,7 +124,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.defense.gouv.fr/sites/default/files/desinformation/Rapport%20Storm%201516%20-%20SGDSN.pdf', reportDate: '2025-05-02' }); - console.log(` ✓ Source Storm 1516 (comptes sociaux) chargée: ${storm1516SocialAccounts.length} comptes`); + this.log(` ✓ Source Storm 1516 (comptes sociaux) chargée: ${storm1516SocialAccounts.length} comptes`); } // Source 7: Pravda @@ -129,7 +136,7 @@ class SuspiciousSitesManager { reportUrl: 'https://www.sgdsn.gouv.fr/files/files/20240212_NP_SGDSN_VIGINUM_PORTAL-KOMBAT-NETWORK_ENG_VF.pdf', reportDate: '2024-12-02' }); - console.log(` ✓ Source Pravda chargée: ${pravdaDomains.length} domaines`); + this.log(` ✓ Source Pravda chargée: ${pravdaDomains.length} domaines`); } // Source 8: Doppelganger - noms de domaines @@ -141,7 +148,7 @@ class SuspiciousSitesManager { reportUrl: 'https://en.wikipedia.org/wiki/List_of_political_disinformation_website_campaigns_in_Russia', reportDate: '2023-11-23' }); - console.log(` ✓ Source Doppelganger chargée: ${doppelgangerDomains.length} domaines`); + this.log(` ✓ Source Doppelganger chargée: ${doppelgangerDomains.length} domaines`); } // Source 9: InfoRos - noms de domaines @@ -153,7 +160,7 @@ class SuspiciousSitesManager { reportUrl: 'https://openfacto.fr/2022/01/27/the-grus-galaxy-of-russian-speaking-websites/', reportDate: '2022-01-27' }); - console.log(` ✓ Source InfoRos chargée: ${infoRosDomains.length} domaines`); + this.log(` ✓ Source InfoRos chargée: ${infoRosDomains.length} domaines`); } // Source 10: Laundromat - noms de domaines @@ -165,7 +172,7 @@ class SuspiciousSitesManager { reportUrl: 'https://securingdemocracy.gmfus.org/wp-content/uploads/2024/05/Laundromat-Paper.pdf', reportDate: '2024-05-01' }); - console.log(` ✓ Source laundromat chargée: ${laundromatDomains.length} domaines`); + this.log(` ✓ Source laundromat chargée: ${laundromatDomains.length} domaines`); } // Avertissement si aucune source n'est chargée @@ -247,20 +254,20 @@ class SuspiciousSitesManager { * Affiche les statistiques dans la console */ logStats() { - console.log('📊 Statistiques:'); - console.log(` Total: ${this.stats.totalSites} entrées`); - console.log(` - Domaines: ${this.stats.totalDomains}`); - console.log(` - Comptes sociaux: ${this.stats.totalSocialAccounts}`); + this.log('📊 Statistiques:'); + this.log(` Total: ${this.stats.totalSites} entrées`); + this.log(` - Domaines: ${this.stats.totalDomains}`); + this.log(` - Comptes sociaux: ${this.stats.totalSocialAccounts}`); if (this.stats.totalSocialAccounts > 0) { - console.log(' Répartition par plateforme:'); + this.log(' Répartition par plateforme:'); for (const [platform, count] of Object.entries(this.stats.bySocialPlatform)) { - console.log(` • ${platform}: ${count}`); + this.log(` • ${platform}: ${count}`); } } - console.log(` Risque élevé: ${this.stats.byRiskLevel.high || 0}`); - console.log(` Risque moyen: ${this.stats.byRiskLevel.medium || 0}`); - console.log(` Risque faible: ${this.stats.byRiskLevel.low || 0}`); - console.log(` Sources: ${Object.keys(this.stats.bySources).length}`); + this.log(` Risque élevé: ${this.stats.byRiskLevel.high || 0}`); + this.log(` Risque moyen: ${this.stats.byRiskLevel.medium || 0}`); + this.log(` Risque faible: ${this.stats.byRiskLevel.low || 0}`); + this.log(` Sources: ${Object.keys(this.stats.bySources).length}`); } /** @@ -317,7 +324,7 @@ class SuspiciousSitesManager { } if (isMatch) { - console.log(`🎯 DIMA: Match trouvé!`, { + this.log(`🎯 DIMA: Match trouvé!`, { type: matchType, site: site.handle || site.domain, url: url @@ -399,7 +406,7 @@ class SuspiciousSitesManager { } if (extractedHandle) { - console.log(`🔍 DIMA: Comparaison - URL: "${extractedHandle}" vs DB: "${handle}"`); + this.log(`🔍 DIMA: Comparaison - URL: "${extractedHandle}" vs DB: "${handle}"`); return extractedHandle === handle; } @@ -464,7 +471,7 @@ class SuspiciousSitesManager { const match = pathname.match(pattern.regex); if (match) { const handle = match[1]; - console.log(`DIMA: Handle extrait de ${accountType}: ${handle}`); + this.log(`DIMA: Handle extrait de ${accountType}: ${handle}`); return handle; } diff --git a/modules/contentExtractor.js b/modules/contentExtractor.js index 15a7806..3b5e599 100644 --- a/modules/contentExtractor.js +++ b/modules/contentExtractor.js @@ -219,35 +219,64 @@ class ContentExtractor { cleanText(text) { if (!text) return ""; + // On préserve toutes les écritures (cyrillique, chinois, arabe, etc.): + // \p{L} = lettres tous scripts, \p{N} = chiffres, \p{M} = signes diacritiques. + // L'ancienne regex basée sur \w (ASCII) stripait le contenu non latin — + // bloquant l'analyse précisément sur les médias que le plugin cible. return text - .replace(/\s+/g, " ") .replace(/[\r\n\t]/g, " ") - .replace(/[^\w\s\.,!?;:()\-'"%àâäéèêëïîôöùûüÿç]/gi, "") + .replace(/[^\p{L}\p{N}\p{M}\s\.,!?;:()\-'"%]/gu, "") + .replace(/\s+/g, " ") .trim(); } detectPageType() { - const url = window.location.href.toLowerCase(); - if ( - url.includes("news") || - url.includes("article") || - url.includes("actualit") - ) - return "news"; - if (url.includes("blog")) return "blog"; + // On distingue le hostname du reste de l'URL pour éviter que des + // sous-chaînes comme "news" matchent dans une querystring quelconque, + // et on utilise des frontières non alphanumériques pour distinguer + // "news.example.com" de "businessnews.example.com". + let hostname = ""; + let pathAndQuery = ""; + try { + const u = new URL(window.location.href); + hostname = u.hostname.toLowerCase(); + pathAndQuery = (u.pathname + u.search).toLowerCase(); + } catch (_) { + const url = window.location.href.toLowerCase(); + hostname = url; + pathAndQuery = url; + } + + // matchWord exige une frontière complète (ex: `news` ne matche pas + // `businessnews`); matchStem n'exige qu'une frontière de début pour + // accepter les pluriels / dérivés (`product` → `products`, `shop` → `shopping`). + const matchWord = (haystack, word) => + new RegExp(`(^|[^a-z0-9])${word}([^a-z0-9]|$)`).test(haystack); + const matchStem = (haystack, stem) => + new RegExp(`(^|[^a-z0-9])${stem}`).test(haystack); + + // Réseaux sociaux : on cible le hostname pour éviter qu'un article + // mentionnant "facebook" dans l'URL d'un autre média soit classé "social". + const socialHosts = ["facebook.com", "twitter.com", "x.com", "instagram.com", "t.me", "telegram.me", "tiktok.com", "vk.com"]; + if (socialHosts.some((h) => hostname === h || hostname.endsWith("." + h))) return "social"; + if ( - url.includes("facebook") || - url.includes("twitter") || - url.includes("instagram") - ) - return "social"; + matchWord(hostname, "news") || + matchWord(pathAndQuery, "news") || + matchWord(pathAndQuery, "article") || + matchStem(pathAndQuery, "actualit") + ) return "news"; + + if (matchWord(hostname, "blog") || matchWord(pathAndQuery, "blog")) return "blog"; + if ( - url.includes("shop") || - url.includes("buy") || - url.includes("product") || - url.includes("commerce") - ) - return "commerce"; + matchStem(hostname, "shop") || + matchStem(pathAndQuery, "shop") || + matchStem(pathAndQuery, "product") || + matchWord(pathAndQuery, "commerce") || + matchWord(pathAndQuery, "buy") + ) return "commerce"; + return "general"; } } diff --git a/modules/uiManager.js b/modules/uiManager.js index f0ac365..430bff5 100644 --- a/modules/uiManager.js +++ b/modules/uiManager.js @@ -28,42 +28,56 @@ class UIManager { if (analysisResults) { this.analysisResults = analysisResults; } - + if (!this.analysisResults) { console.error('DIMA: Aucun résultat d\'analyse disponible pour créer le bouton'); return; } // Vérifier si le site est suspect - this.suspiciousSiteCheck = window.checkSuspiciousSite ? - window.checkSuspiciousSite(window.location.href) : + this.suspiciousSiteCheck = window.checkSuspiciousSite ? + window.checkSuspiciousSite(window.location.href) : { isSuspicious: false }; - + try { // Supprimer bouton existant document.getElementById('dima-btn')?.remove(); document.getElementById('dima-suspicious-alert')?.remove(); - if (this.buttonCreated) return; + // Couleur du bouton: blindée pour éviter qu'une valeur invalide + // ne produise un gradient cassé via adjustColor. + const safeRiskColor = this.sanitizeHexColor(this.analysisResults.riskColor); - // Créer le bouton principal + // Créer le bouton principal (construction DOM-safe: jamais d'innerHTML + // sur des valeurs dérivées des résultats d'analyse). const button = document.createElement('div'); button.id = 'dima-btn'; - - button.innerHTML = ` -
- 🧠 - ${this.analysisResults.globalScore} - ${this.analysisResults.riskLevel} -
- `; - + + const inner = document.createElement('div'); + inner.style.cssText = 'display: flex; align-items: center; gap: 8px;'; + + const brain = document.createElement('span'); + brain.textContent = '🧠'; + + const scoreSpan = document.createElement('span'); + scoreSpan.style.fontWeight = 'bold'; + scoreSpan.textContent = String(this.analysisResults.globalScore ?? ''); + + const levelSpan = document.createElement('span'); + levelSpan.style.cssText = 'font-size: 0.8em; opacity: 0.9;'; + levelSpan.textContent = String(this.analysisResults.riskLevel ?? ''); + + inner.appendChild(brain); + inner.appendChild(scoreSpan); + inner.appendChild(levelSpan); + button.appendChild(inner); + button.style.cssText = ` position: fixed !important; top: 20px !important; right: 20px !important; z-index: 999999999 !important; - background: linear-gradient(135deg, ${this.analysisResults.riskColor}, ${this.adjustColor(this.analysisResults.riskColor, -20)}) !important; + background: linear-gradient(135deg, ${safeRiskColor}, ${this.adjustColor(safeRiskColor, -20)}) !important; color: white !important; padding: 12px 16px !important; border-radius: 25px !important; @@ -474,6 +488,21 @@ class UIManager { return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback; } + // Échappe une chaîne pour insertion sûre dans un fragment HTML. + // Utilisé pour les valeurs dérivées des bases de données (technique.nom, + // .index, .description, .tactic, matchedKeywords[].keyword) qui sont + // contrôlées par les contributeurs des bases mais ne sont pas garanties + // exemptes de caractères HTML; même remarque pour les valeurs page-controlled. + escapeHtml(value) { + if (value == null) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + isSafeHttpUrl(url) { if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { return false; @@ -751,43 +780,120 @@ ${techniques.map(t => `• ${t.nom}`).join('\n')}`; `; } + // Note: Toutes les valeurs dérivées de l'analyse (technique.*, + // matchedKeywords, page title/url) sont injectées via this.escapeHtml() + // pour éviter qu'un caractère HTML dans une base de données ne casse + // le rendu ou n'introduise un vecteur XSS. Les onclick/onerror inline + // ont été remplacés par des addEventListener (CSP-safe). + const esc = (v) => this.escapeHtml(v); + const phaseIcon = (phase) => { + switch (phase) { + case 'Detect': return '👁️'; + case 'Informer': return '📢'; + case 'Mémoriser': return '🧠'; + case 'Agir': return '⚡'; + default: return '•'; + } + }; + + const safeRiskColor = this.sanitizeHexColor(this.analysisResults.riskColor); + const detectedHtml = this.analysisResults.detectedTechniques.length === 0 + ? ` +
+
+
Aucune manipulation détectée
+
Le contenu analysé semble exempt de techniques de manipulation cognitive manifestes
+
+ ` + : ` +
+

⚠️ Techniques de manipulation détectées

+
+ ${this.analysisResults.detectedTechniques.slice(0, 8).map(technique => ` +
+
+
+
+ ${esc(phaseIcon(technique.phase))} ${esc(technique.index)}: ${esc(technique.nom)} +
+ ${technique.tactic ? `
↳ Tactique: ${esc(technique.tactic)}
` : ''} + ${technique.description ? `
${esc(technique.description)}
` : ''} +
+ + ${esc(technique.confidence)}% + +
+ +
+ + ${esc(technique.phase)} + +
+
Score pondéré: ${esc(technique.weightedScore?.toFixed(1) ?? technique.score)}
+
+
+ + ${technique.matchedKeywords?.length > 0 ? ` +
+
+ 🔎 Mots-clés détectés: +
+
+ ${technique.matchedKeywords.slice(0, 4).map(keyword => + ` + ${esc(keyword.keyword)} ${(keyword.count > 1) ? `(×${esc(keyword.count)})` : ''} + ` + ).join('')} + ${technique.matchedKeywords.length > 4 ? + `+${esc(technique.matchedKeywords.length - 4)} autres...` + : '' + } +
+
+ ` : ''} +
+ `).join('')} +
+
+ `; + modal.innerHTML = `
- +
- M82 Project +

Analyse DIMA

- Détection de techniques de manipulation cognitive par - M82 Project

- + ${suspiciousAlert} - +
-
-
${this.analysisResults.globalScore}
+
+
${esc(this.analysisResults.globalScore)}
Score Global
-
${this.analysisResults.detectedTechniques.length}
+
${esc(this.analysisResults.detectedTechniques.length)}
Techniques
-
${this.analysisResults.riskLevel}
+
${esc(this.analysisResults.riskLevel)}
Niveau Risque
-
${this.analysisResults.contentLength}
+
${esc(this.analysisResults.contentLength)}
Caractères
@@ -800,78 +906,21 @@ ${techniques.map(t => `• ${t.nom}`).join('\n')}`;
- Analysé le ${new Date(this.analysisResults.timestamp).toLocaleString('fr-FR')} • - ${this.analysisResults.analyzedText} caractères traités • Type: ${this.pageType} + Analysé le ${esc(new Date(this.analysisResults.timestamp).toLocaleString('fr-FR'))} • + ${esc(this.analysisResults.analyzedText)} caractères traités • Type: ${esc(this.pageType)}
- - ${this.analysisResults.detectedTechniques.length === 0 ? ` -
-
-
Aucune manipulation détectée
-
Le contenu analysé semble exempt de techniques de manipulation cognitive manifestes
-
- ` : ` -
-

⚠️ Techniques de manipulation détectées

-
- ${this.analysisResults.detectedTechniques.slice(0, 8).map(technique => ` -
-
-
-
- ${technique.phase === 'Detect' ? '👁️' : technique.phase === 'Informer' ? '📢' : technique.phase === 'Mémoriser' ? '🧠' : '⚡'} ${technique.index}: ${technique.nom} -
- ${technique.tactic ? `
↳ Tactique: ${technique.tactic}
` : ''} - ${technique.description ? `
${technique.description}
` : ''} -
- - ${technique.confidence}% - -
- -
- - ${technique.phase} - -
-
Score pondéré: ${technique.weightedScore?.toFixed(1) || technique.score}
-
-
- - ${technique.matchedKeywords?.length > 0 ? ` -
-
- 🔎 Mots-clés détectés: -
-
- ${technique.matchedKeywords.slice(0, 4).map(keyword => - ` - ${keyword.keyword} ${(keyword.count > 1) ? `(×${keyword.count})` : ''} - ` - ).join('')} - ${technique.matchedKeywords.length > 4 ? - `+${technique.matchedKeywords.length - 4} autres...` - : '' - } -
-
- ` : ''} -
- `).join('')} -
-
- `} - + ${detectedHtml} +
- - @@ -879,18 +928,6 @@ ${techniques.map(t => `• ${t.nom}`).join('\n')}`;