@anonympins/fingerprint 0.6.7 → 0.7.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +3 -7
  3. package/composer.json +8 -1
  4. package/package.json +1 -1
  5. package/public/fingerprint-wordpress.zip +0 -0
  6. package/public/fp.wasm +0 -0
  7. package/src/js/build-client.js +9 -11
  8. package/src/js/dynamic-wasm.js +7 -7
  9. package/src/js/fingerprint.builder.js +37 -38
  10. package/src/js/fingerprint.client.js +236 -152
  11. package/src/js/fingerprint.client.obfuscated.js +1 -1
  12. package/src/js/fingerprint.js +517 -101
  13. package/src/js/fingerprint.utils.js +213 -213
  14. package/src/js/library.js +355 -384
  15. package/src/js/mongodb-store.js +1 -1
  16. package/src/js/optimization.worker.js +27 -27
  17. package/src/js/pow.solver.inline.js +206 -62
  18. package/src/js/pow.solver.js +235 -101
  19. package/src/js/pow.worker.js +48 -26
  20. package/src/js/redis-store.js +1 -1
  21. package/src/js/tests/fingerprint.client.init.test.js +146 -143
  22. package/src/js/tests/fingerprint.test.js +101 -40
  23. package/src/js/tests/ip-reputation.test.js +1 -0
  24. package/src/js/upow-model-task.js +14 -15
  25. package/src/php/AutoTuner.php +34 -34
  26. package/src/php/Challenge/ChallengeUtils.php +204 -32
  27. package/src/php/Config/SecurityProfiles.php +21 -0
  28. package/src/php/DirectFingerprint.php +190 -145
  29. package/src/php/FingerprintBuilder.php +29 -30
  30. package/src/php/FingerprintClient.php +24 -25
  31. package/src/php/FingerprintEngine.php +1836 -1880
  32. package/src/php/Ja3AnomalyDetector.php +258 -227
  33. package/src/php/Optimization/FunctionRegistry.php +7 -7
  34. package/src/php/Optimization/Optimization.php +24 -24
  35. package/src/php/Optimization/OptimizationOperators.php +28 -27
  36. package/src/php/Optimization/ProblemInitializers.php +52 -52
  37. package/src/php/ProblemManager.php +31 -34
  38. package/src/php/RequestContext.php +8 -1
  39. package/src/php/Store/IStore.php +7 -8
  40. package/src/php/Store/InMemoryStore.php +66 -66
  41. package/src/php/Store/MongoDbStore.php +5 -5
  42. package/src/php/Store/RedisStore.php +5 -5
  43. package/src/php/Store/StoreManager.php +35 -35
  44. package/src/php/Tests/ChallengeUtilsTest.php +453 -453
  45. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  46. package/src/php/Tests/FingerprintEngineTest.php +662 -693
  47. package/src/php/Tests/IpReputationTest.php +10 -0
  48. package/src/php/Tests/MaliciousPatternsTest.php +103 -103
  49. package/src/php/Tests/MetricsTest.php +97 -97
  50. package/src/php/Tests/ProblemManagerTest.php +376 -376
  51. package/src/php/Tests/QuicFingerprintTest.php +53 -53
  52. package/src/php/Tests/RequestUtilsTest.php +471 -471
  53. package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
  54. package/src/php/Tests/config/ed25519_key.json +3 -3
  55. package/src/php/Utils/Env.php +49 -49
  56. package/src/php/Utils/MaliciousPatterns.php +74 -74
  57. package/src/php/Utils/MetricsManager.php +209 -209
  58. package/src/php/Utils/RequestUtils.php +236 -19
  59. package/src/php/Utils/TLSClientHelloParser.php +369 -212
  60. package/src/php/WordPress/WpDbStore.php +155 -0
  61. package/src/php/WordPress/fingerprint-wordpress.php +217 -0
  62. package/src/php/WordPress/package.php +123 -0
  63. package/src/php/bin/auto-tune.php +118 -118
@@ -0,0 +1,155 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\WordPress;
6
+
7
+ use Anonympins\Fingerprint\Store\IStore;
8
+
9
+ // Polyfill pour ARRAY_A si chargé hors du cycle de vie standard de WordPress
10
+ if (!defined('ARRAY_A')) {
11
+ define('ARRAY_A', 'ARRAY_A');
12
+ }
13
+
14
+ /**
15
+ * Adaptateur IStore persistant pour WordPress exploitant l'objet $wpdb.
16
+ * Permet de stocker les sessions d'appareils, nonces et états de PoW
17
+ * sans dépendre d'une extension mémoire comme Redis ou APCu.
18
+ */
19
+ class WpDbStore implements IStore
20
+ {
21
+ /** @var \wpdb|object|null */
22
+ private $db;
23
+ private string $table;
24
+
25
+ public function __construct($db = null)
26
+ {
27
+ global $wpdb;
28
+ $this->db = $db ?? $wpdb;
29
+ $prefix = (is_object($this->db) && isset($this->db->prefix)) ? $this->db->prefix : 'wp_';
30
+ $this->table = $prefix . 'fingerprint_store';
31
+ }
32
+
33
+ /**
34
+ * Crée la table de stockage si elle n'existe pas encore.
35
+ */
36
+ public function ensureTable(): void
37
+ {
38
+ if (!$this->db || !method_exists($this->db, 'get_charset_collate')) {
39
+ return;
40
+ }
41
+
42
+ $charsetCollate = $this->db->get_charset_collate();
43
+ $sql = "CREATE TABLE IF NOT EXISTS {$this->table} (
44
+ `key_id` VARCHAR(191) NOT NULL,
45
+ `value` LONGTEXT NOT NULL,
46
+ `expires_at` BIGINT UNSIGNED NULL,
47
+ PRIMARY KEY (`key_id`),
48
+ KEY `expires_idx` (`expires_at`)
49
+ ) {$charsetCollate};";
50
+
51
+ // Chargement sécurisé de dbDelta si ABSPATH est présent
52
+ if (!function_exists('dbDelta') && defined('ABSPATH')) {
53
+ $upgradeFile = ABSPATH . 'wp-admin/includes/upgrade.php';
54
+ if (file_exists($upgradeFile)) {
55
+ require_once $upgradeFile;
56
+ }
57
+ }
58
+
59
+ if (function_exists('dbDelta')) {
60
+ dbDelta($sql);
61
+ } elseif (method_exists($this->db, 'query')) {
62
+ $this->db->query($sql);
63
+ }
64
+ }
65
+
66
+ public function get(string $key)
67
+ {
68
+ if (!$this->db || !method_exists($this->db, 'prepare')) {
69
+ return null;
70
+ }
71
+
72
+ $sql = $this->db->prepare(
73
+ "SELECT `value`, `expires_at` FROM {$this->table} WHERE `key_id` = %s",
74
+ $key
75
+ );
76
+ $outputType = defined('ARRAY_A') ? \ARRAY_A : 'ARRAY_A';
77
+ $row = $this->db->get_row($sql, $outputType);
78
+
79
+ if (!$row) {
80
+ return null;
81
+ }
82
+
83
+ if ($row['expires_at'] !== null && (int)$row['expires_at'] < time()) {
84
+ $this->delete($key);
85
+ return null;
86
+ }
87
+
88
+ $decoded = json_decode((string)$row['value'], true);
89
+ return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $row['value'];
90
+ }
91
+
92
+ public function set(string $key, $value, ?int $ttl = null): void
93
+ {
94
+ if (!$this->db || !method_exists($this->db, 'replace')) {
95
+ return;
96
+ }
97
+
98
+ $expiresAt = ($ttl !== null && $ttl > 0) ? (time() + $ttl) : null;
99
+ $serialized = is_scalar($value) && !is_bool($value)
100
+ ? (string)$value
101
+ : json_encode($value, JSON_UNESCAPED_SLASHES);
102
+
103
+ $this->db->replace(
104
+ $this->table,
105
+ [
106
+ 'key_id' => $key,
107
+ 'value' => $serialized,
108
+ 'expires_at' => $expiresAt
109
+ ],
110
+ ['%s', '%s', $expiresAt !== null ? '%d' : null]
111
+ );
112
+ }
113
+
114
+ public function has(string $key): bool
115
+ {
116
+ return $this->get($key) !== null;
117
+ }
118
+
119
+ public function delete(string $key): void
120
+ {
121
+ if (!$this->db || !method_exists($this->db, 'delete')) {
122
+ return;
123
+ }
124
+
125
+ $this->db->delete(
126
+ $this->table,
127
+ ['key_id' => $key],
128
+ ['%s']
129
+ );
130
+ }
131
+
132
+ public function clear(): void
133
+ {
134
+ if (!$this->db || !method_exists($this->db, 'query')) {
135
+ return;
136
+ }
137
+ $this->db->query("TRUNCATE TABLE {$this->table}");
138
+ }
139
+
140
+ /**
141
+ * Nettoie les lignes expirées (appelé par le WP-Cron périodique).
142
+ */
143
+ public function pruneExpired(): int
144
+ {
145
+ if (!$this->db || !method_exists($this->db, 'prepare')) {
146
+ return 0;
147
+ }
148
+
149
+ $now = time();
150
+ $deleted = $this->db->query(
151
+ $this->db->prepare("DELETE FROM {$this->table} WHERE `expires_at` IS NOT NULL AND `expires_at` < %d", $now)
152
+ );
153
+ return is_numeric($deleted) ? (int)$deleted : 0;
154
+ }
155
+ }
@@ -0,0 +1,217 @@
1
+ <?php
2
+ /**
3
+ * Plugin Name: Fingerprint Anti-Bot & Proof-of-Work
4
+ * Plugin URI: https://github.com/anonympins/fingerprint
5
+ * Description: Protection bot haute performance pour WordPress avec Proof-of-Work client et détection d'anomalies.
6
+ * Version: 0.7.1
7
+ * Author: Anonympins
8
+ * License: MIT
9
+ */
10
+
11
+ declare(strict_types=1);
12
+
13
+ use Anonympins\Fingerprint\DirectFingerprint;
14
+ use Anonympins\Fingerprint\Store\StoreManager;
15
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
16
+ use Anonympins\Fingerprint\WordPress\WpDbStore;
17
+
18
+ if (!defined('ABSPATH')) {
19
+ exit;
20
+ }
21
+
22
+ // =============================================================================
23
+ // PIÈGES HONEYPOT POUR ENVIRONNEMENTS TIERS (NON-WORDPRESS)
24
+ // Détecte et bloque instantanément les scans de vulnérabilités ciblant
25
+ // Laravel, Symfony, Spring Boot, Django, ASP.NET, phpMyAdmin, Git, Cloud, etc.
26
+ // =============================================================================
27
+ $foreignEnvTrapUrls = [
28
+ // Outils d'administration BDD & scripts de debug
29
+ '/phpmyadmin', '/pma', '/adminer', '/mysql', '/dbadmin', '/phpinfo',
30
+ // Frameworks PHP (Laravel, Symfony) & fichiers d'environnement
31
+ '/.env', '/artisan', '/telescope', '/_profiler', '/config/database.php', '/vendor/',
32
+ // Java / Spring Boot / Actuator
33
+ '/actuator', '/actuator/health', '/actuator/gateway', '/api-docs', '/swagger-ui',
34
+ // Python / Django / Flask
35
+ '/.flask', '/django_admin', '/__pycache__',
36
+ // Node.js & configuration paquets
37
+ '/.npmrc', '/package.json', '/package-lock.json',
38
+ // ASP.NET / IIS / CGI
39
+ '/elmah.axd', '/trace.axd', '/cgi-bin/',
40
+ // DevOps, VCS & Déploiements cloud
41
+ '/.git', '/.svn', '/.aws', '/.kube', '/docker-compose', '/serverless'
42
+ ];
43
+
44
+ $foreignEnvHoneypotFields = [
45
+ // Tokens CSRF et formulaires spécifiques à d'autres frameworks
46
+ '_token', // Laravel
47
+ 'csrfmiddlewaretoken', // Django
48
+ 'authenticity_token', // Ruby on Rails
49
+ 'form_build_id', // Drupal
50
+ 'form_id', // Drupal
51
+ 'j_username', // Java / Spring Security
52
+ 'j_password', // Java / Spring Security
53
+ '__VIEWSTATE', // ASP.NET WebForms
54
+ '__EVENTVALIDATION', // ASP.NET WebForms
55
+ ];
56
+
57
+ // =============================================================================
58
+ // CONFIGURATION RAPIDE DES PROFILS DE SÉCURITÉ PAR CONTEXTE
59
+ // =============================================================================
60
+ $fingerprintSecurityProfiles = [
61
+ 'api' => [
62
+ 'profile' => 'api',
63
+ 'overrides' => [
64
+ 'verbose' => false,
65
+ 'challengeNewDevices' => false,
66
+ 'honeypot' => [
67
+ 'fields' => $foreignEnvHoneypotFields,
68
+ 'trapUrls' => $foreignEnvTrapUrls,
69
+ 'detectInjections' => true,
70
+ ],
71
+ ],
72
+ ],
73
+ 'admin' => [
74
+ 'profile' => 'strict',
75
+ 'overrides' => [
76
+ 'verbose' => true,
77
+ 'challengeNewDevices' => true,
78
+ 'honeypot' => [
79
+ 'fields' => $foreignEnvHoneypotFields,
80
+ 'trapUrls' => $foreignEnvTrapUrls,
81
+ 'detectInjections' => true,
82
+ ],
83
+ ],
84
+ ],
85
+ 'frontend' => [
86
+ 'profile' => 'blog',
87
+ 'overrides' => [
88
+ 'verbose' => false,
89
+ 'challengeNewDevices' => false,
90
+ 'honeypot' => [
91
+ 'fields' => $foreignEnvHoneypotFields,
92
+ 'trapUrls' => $foreignEnvTrapUrls,
93
+ 'detectInjections' => true,
94
+ ],
95
+ ],
96
+ ],
97
+ ];
98
+
99
+ // 1. Autoloader PSR-4 pour le moteur Fingerprint
100
+ if (!class_exists(DirectFingerprint::class)) {
101
+ $composerPaths = [
102
+ __DIR__ . '/vendor/autoload.php',
103
+ dirname(__DIR__, 2) . '/vendor/autoload.php',
104
+ dirname(__DIR__, 3) . '/vendor/autoload.php',
105
+ (defined('ABSPATH') ? ABSPATH . 'vendor/autoload.php' : ''),
106
+ ];
107
+ foreach ($composerPaths as $cPath) {
108
+ if (!empty($cPath) && file_exists($cPath)) {
109
+ require_once $cPath;
110
+ break;
111
+ }
112
+ }
113
+
114
+ spl_autoload_register(function (string $class): void {
115
+ $prefix = 'Anonympins\\Fingerprint\\';
116
+ $len = strlen($prefix);
117
+ if (strncmp($prefix, $class, $len) !== 0) {
118
+ return;
119
+ }
120
+
121
+ $relativeClass = substr($class, $len);
122
+
123
+ // Classes spécifiques WordPress (ex: WpDbStore)
124
+ if (str_starts_with($relativeClass, 'WordPress\\')) {
125
+ $localClass = substr($relativeClass, strlen('WordPress\\'));
126
+ $localFile = __DIR__ . '/' . str_replace('\\', '/', $localClass) . '.php';
127
+ if (file_exists($localFile)) {
128
+ require_once $localFile;
129
+ return;
130
+ }
131
+ }
132
+
133
+ // Chemins candidats pour le moteur PHP (packagé ou environnement dev)
134
+ $candidateDirs = [
135
+ __DIR__ . '/src/',
136
+ __DIR__ . '/includes/',
137
+ __DIR__ . '/',
138
+ dirname(__DIR__) . '/',
139
+ ];
140
+
141
+ $fileRelative = str_replace('\\', '/', $relativeClass) . '.php';
142
+ foreach ($candidateDirs as $dir) {
143
+ $fullPath = $dir . $fileRelative;
144
+ if (file_exists($fullPath)) {
145
+ require_once $fullPath;
146
+ return;
147
+ }
148
+ }
149
+ });
150
+ }
151
+
152
+ // 2. Activation du plugin : Création de la table de cache SQL
153
+ register_activation_hook(__FILE__, function () {
154
+ $store = new WpDbStore();
155
+ $store->ensureTable();
156
+
157
+ if (!wp_next_scheduled('fingerprint_prune_expired_entries')) {
158
+ wp_schedule_event(time(), 'hourly', 'fingerprint_prune_expired_entries');
159
+ }
160
+ });
161
+
162
+ // 3. Désactivation : Nettoyage du WP-Cron
163
+ register_deactivation_hook(__FILE__, function () {
164
+ wp_clear_scheduled_hook('fingerprint_prune_expired_entries');
165
+ });
166
+
167
+ // 4. Tâche de fond WP-Cron : Purge des clés expirées dans la BDD
168
+ add_action('fingerprint_prune_expired_entries', function () {
169
+ $store = new WpDbStore();
170
+ $store->pruneExpired();
171
+ });
172
+
173
+ // 5. Avertissement Admin si le site n'utilise pas HTTPS (Environnement Non Sécurisé)
174
+ add_action('admin_notices', function () {
175
+ if (!is_ssl()) {
176
+ echo '<div class="notice notice-warning is-dismissible">';
177
+ echo '<p><strong>[Fingerprint Anti-Bot Security Warning]</strong> : Votre site fonctionne actuellement sous le protocole non chiffré <code>HTTP</code>. ';
178
+ echo 'Dans ce mode, l\'API native <em>Web Cryptography</em> (<code>crypto.subtle</code>) des navigateurs est désactivée par mesure de sécurité par les navigateurs modernes, ';
179
+ echo 'forçant l\'exécution d\'un simulacre/fallback purement JavaScript plus lent et vulnérable aux attaques par interception (MitM). ';
180
+ echo 'Il est fortement recommandé de déployer un certificat TLS/SSL et de forcer <code>HTTPS</code> pour garantir l\'intégrité des calculs PoW et la protection des cookies d\'identité.</p>';
181
+ echo '</div>';
182
+ }
183
+ });
184
+
185
+ // 5. Interception de sécurité au plus tôt du cycle de vie WordPress
186
+ add_action('plugins_loaded', function () use ($fingerprintSecurityProfiles) {
187
+ // Ignore WP-CLI et les exécutions internes de cron
188
+ if ((defined('WP_CLI') && WP_CLI) || (defined('DOING_CRON') && DOING_CRON)) {
189
+ return;
190
+ }
191
+
192
+ // Initialiser le Store persistant basé sur $wpdb
193
+ $store = new WpDbStore();
194
+ StoreManager::configureStore($store);
195
+
196
+ $requestUri = $_SERVER['REQUEST_URI'] ?? '/';
197
+ $isRestApi = defined('REST_REQUEST') && REST_REQUEST;
198
+ $isAdmin = is_admin() || str_contains($requestUri, 'wp-login.php');
199
+
200
+ // Possibilité de filtrer la configuration via functions.php ou un thème
201
+ $configs = apply_filters('fingerprint_security_profiles', $fingerprintSecurityProfiles);
202
+
203
+ // Sélection adaptative du profil selon la cible de la requête
204
+ if ($isRestApi || str_starts_with($requestUri, '/wp-json/')) {
205
+ $contextConfig = $configs['api'] ?? $fingerprintSecurityProfiles['api'];
206
+ } elseif ($isAdmin) {
207
+ $contextConfig = $configs['admin'] ?? $fingerprintSecurityProfiles['admin'];
208
+ } else {
209
+ $contextConfig = $configs['frontend'] ?? $fingerprintSecurityProfiles['frontend'];
210
+ }
211
+
212
+ $securityConfig = SecurityProfiles::createSecurityProfile($contextConfig['profile'], $contextConfig['overrides'] ?? []);
213
+ $guard = new DirectFingerprint($securityConfig);
214
+
215
+ // Inspecte la requête : bloque ou envoie le challenge si nécessaire et stoppe le script
216
+ $guard->protect();
217
+ }, 0);
@@ -0,0 +1,123 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * Script d'empaquetage automatique du plugin WordPress.
7
+ * Copie les classes du moteur PHP, les assets JS et génère un ZIP prêt à être installé.
8
+ *
9
+ * Usage:
10
+ * php src/php/WordPress/package.php
11
+ */
12
+
13
+ if (!extension_loaded('zip')) {
14
+ fwrite(STDERR, "Erreur : L'extension PHP 'zip' est requise pour créer l'archive.\n");
15
+ exit(1);
16
+ }
17
+
18
+ $rootDir = dirname(__DIR__, 3);
19
+ $phpSrcDir = $rootDir . '/src/php';
20
+ $jsSrcDir = $rootDir . '/src/js';
21
+ $wpDir = $phpSrcDir . '/WordPress';
22
+ $distDir = $rootDir . '/public';
23
+ $pluginSlug = 'fingerprint-wordpress';
24
+ $buildDir = $distDir . '/' . $pluginSlug;
25
+ $zipFile = $distDir . '/' . $pluginSlug . '.zip';
26
+
27
+ echo "==> Début du packaging du plugin WordPress...\n";
28
+
29
+ // 1. Nettoyage du répertoire de build précédent
30
+ if (is_dir($buildDir)) {
31
+ $iterator = new RecursiveIteratorIterator(
32
+ new RecursiveDirectoryIterator($buildDir, FilesystemIterator::SKIP_DOTS),
33
+ RecursiveIteratorIterator::CHILD_FIRST
34
+ );
35
+ foreach ($iterator as $item) {
36
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
37
+ }
38
+ rmdir($buildDir);
39
+ }
40
+ if (file_exists($zipFile)) {
41
+ unlink($zipFile);
42
+ }
43
+
44
+ @mkdir($buildDir . '/src', 0777, true);
45
+ @mkdir($buildDir . '/assets', 0777, true);
46
+
47
+ // 2. Copie des fichiers principaux du plugin WordPress
48
+ copy($wpDir . '/fingerprint-wordpress.php', $buildDir . '/fingerprint-wordpress.php');
49
+ copy($wpDir . '/WpDbStore.php', $buildDir . '/WpDbStore.php');
50
+
51
+ // 3. Copie des assets clients nécessaires (solveur PoW)
52
+ $solverSource = $jsSrcDir . '/pow.solver.inline.js';
53
+ if (file_exists($solverSource)) {
54
+ copy($solverSource, $buildDir . '/assets/pow.solver.inline.js');
55
+ }
56
+
57
+ // 4. Copie récursive de la bibliothèque PHP (src/php -> build/src), en excluant les dossiers de dev
58
+ $excludeDirs = ['WordPress', 'Tests', 'bin'];
59
+ $phpIterator = new RecursiveIteratorIterator(
60
+ new RecursiveDirectoryIterator($phpSrcDir, FilesystemIterator::SKIP_DOTS),
61
+ RecursiveIteratorIterator::SELF_FIRST
62
+ );
63
+
64
+ foreach ($phpIterator as $item) {
65
+ $relativePath = substr($item->getPathname(), strlen($phpSrcDir) + 1);
66
+ $topDir = explode(DIRECTORY_SEPARATOR, str_replace('/', DIRECTORY_SEPARATOR, $relativePath))[0];
67
+
68
+ if (in_array($topDir, $excludeDirs, true)) {
69
+ continue;
70
+ }
71
+
72
+ $destPath = $buildDir . '/src/' . $relativePath;
73
+ if ($item->isDir()) {
74
+ if (!is_dir($destPath)) {
75
+ mkdir($destPath, 0777, true);
76
+ }
77
+ } else {
78
+ copy($item->getPathname(), $destPath);
79
+ }
80
+ }
81
+
82
+ echo "==> Fichiers copiés dans {$buildDir}\n";
83
+
84
+ // 5. Création de l'archive ZIP
85
+ $zip = new ZipArchive();
86
+ if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
87
+ fwrite(STDERR, "Erreur : Impossible de créer le fichier {$zipFile}\n");
88
+ exit(1);
89
+ }
90
+
91
+ $distIterator = new RecursiveIteratorIterator(
92
+ new RecursiveDirectoryIterator($buildDir, FilesystemIterator::SKIP_DOTS),
93
+ RecursiveIteratorIterator::LEAVES_ONLY
94
+ );
95
+
96
+ foreach ($distIterator as $file) {
97
+ if (!$file->isDir()) {
98
+ $filePath = $file->getPathname();
99
+ // Préserve le préfixe du dossier racine dans le ZIP : fingerprint-wordpress/...
100
+ $relativePath = $pluginSlug . '/' . substr($filePath, strlen($buildDir) + 1);
101
+ $zip->addFile($filePath, str_replace('\\', '/', $relativePath));
102
+ }
103
+ }
104
+ function removeDirectory(string $dir): void
105
+ {
106
+ if (!is_dir($dir)) {
107
+ return;
108
+ }
109
+ $iterator = new RecursiveIteratorIterator(
110
+ new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
111
+ RecursiveIteratorIterator::CHILD_FIRST
112
+ );
113
+ foreach ($iterator as $item) {
114
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
115
+ }
116
+ rmdir($dir);
117
+ }
118
+ $zip->close();
119
+ removeDirectory($buildDir);
120
+ $sizeKb = round(filesize($zipFile) / 1024, 2);
121
+ echo "==> Archive ZIP générée avec succès :\n";
122
+ echo " Fichier : {$zipFile} ({$sizeKb} Ko)\n";
123
+ echo " Prêt à être installé via wp-admin ou déployé en production !\n";