@anonympins/fingerprint 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,53 +1,80 @@
1
- /**
2
- * @file Creates a store adapter for MongoDB.
3
- * This adapter uses a collection as a key-value store and leverages MongoDB's TTL indexes
4
- * for automatic expiration of documents.
5
- */
6
-
7
- /**
8
- * Creates a store adapter for a MongoDB collection.
9
- * It's recommended to pass the `db` object and let the adapter handle the collection.
10
- *
11
- * **Note:** For TTL to work, you must create a TTL index on the `expiresAt` field in your collection.
12
- * In the mongo shell, run:
13
- * `db.yourCollectionName.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })`
14
- *
15
- * @param {import('mongodb').Db} db - An instance of a MongoDB Db object.
16
- * @param {string} [collectionName='fingerprint_store'] - The name of the collection to use.
17
- * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
18
- */
19
- export function createMongoDbStore(db, collectionName = 'fingerprint_store') {
20
- const collection = db.collection(collectionName);
21
-
22
- return {
23
- async get(key) {
24
- const doc = await collection.findOne({ _id: key });
25
- // The TTL index automatically removes expired documents, so no need to check `expiresAt` here.
26
- return doc ? doc.value : null;
27
- },
28
- async set(key, value, ttl) {
29
- const doc = {
30
- _id: key,
31
- value: value,
32
- };
33
-
34
- if (ttl && ttl > 0) {
35
- // Set the expiration date for the TTL index.
36
- doc.expiresAt = new Date(Date.now() + ttl * 1000);
37
- }
38
-
39
- await collection.updateOne(
40
- { _id: key },
41
- { $set: doc },
42
- { upsert: true }
43
- );
44
- },
45
- async has(key) {
46
- const count = await collection.countDocuments({ _id: key });
47
- return count > 0;
48
- },
49
- async delete(key) {
50
- await collection.deleteOne({ _id: key });
51
- },
52
- };
1
+ /**
2
+ * @file Creates a store adapter for MongoDB.
3
+ * This adapter uses a collection as a key-value store and leverages MongoDB's TTL indexes
4
+ * for automatic expiration of documents.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for a MongoDB collection.
9
+ * It's recommended to pass the `db` object and let the adapter handle the collection.
10
+ *
11
+ * **Note:** For TTL to work, you must create a TTL index on the `expiresAt` field in your collection.
12
+ * In the mongo shell, run:
13
+ * `db.yourCollectionName.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })`
14
+ *
15
+ * @param {import('mongodb').Db} db - An instance of a MongoDB Db object.
16
+ * @param {string} [collectionName='fingerprint_store'] - The name of the collection to use.
17
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
18
+ */
19
+ export function createMongoDbStore(db, collectionName = 'fingerprint_store') {
20
+ const collection = db.collection(collectionName);
21
+
22
+ // Custom replacer/reviver to handle Set serialization (identical to Redis/SQL stores)
23
+ const replacer = (k, v) => (v instanceof Set ? Array.from(v) : v);
24
+ const reviver = (k, v) => (k === 'ips' && Array.isArray(v) ? new Set(v) : v);
25
+
26
+ return {
27
+ async get(key) {
28
+ const doc = await collection.findOne({ _id: key });
29
+ if (!doc) return null;
30
+
31
+ // Active expiration check to bypass eventual consistency of MongoDB's 60s TTL cleanup daemon
32
+ if (doc.expiresAt && new Date(doc.expiresAt) < new Date()) {
33
+ await this.delete(key);
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ return JSON.parse(doc.value, reviver);
39
+ } catch (e) {
40
+ // Fallback for legacy un-serialized raw values
41
+ return doc.value;
42
+ }
43
+ },
44
+ async set(key, value, ttl) {
45
+ const stringValue = JSON.stringify(value, replacer);
46
+ const doc = {
47
+ _id: key,
48
+ value: stringValue,
49
+ };
50
+
51
+ if (ttl && ttl > 0) {
52
+ // Set the expiration date for the TTL index.
53
+ doc.expiresAt = new Date(Date.now() + ttl * 1000);
54
+ }
55
+
56
+ await collection.updateOne(
57
+ { _id: key },
58
+ { $set: doc },
59
+ { upsert: true }
60
+ );
61
+ },
62
+ async has(key) {
63
+ const doc = await collection.findOne({ _id: key }, { projection: { expiresAt: 1 } });
64
+ if (!doc) return false;
65
+
66
+ if (doc.expiresAt && new Date(doc.expiresAt) < new Date()) {
67
+ await this.delete(key);
68
+ return false;
69
+ }
70
+ return true;
71
+ },
72
+ async delete(key) {
73
+ await collection.deleteOne({ _id: key });
74
+ },
75
+ async init() {
76
+ // Automates index configuration
77
+ await collection.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 });
78
+ }
79
+ };
53
80
  }
@@ -4,8 +4,8 @@
4
4
  * Ce script s'exécute sur un thread séparé pour ne pas bloquer l'interface utilisateur.
5
5
  */
6
6
 
7
- import { parentPort, workerData } from 'worker_threads';
8
- import { Optimization } from './library.js'; // Assurez-vous que le chemin est correct
7
+ import {parentPort, workerData} from 'worker_threads';
8
+ import {Optimization} from './library.js'; // Assurez-vous que le chemin est correct
9
9
 
10
10
  if (parentPort) {
11
11
  parentPort.on('message', async () => { // Le message est vide, on utilise workerData
@@ -1,5 +1,5 @@
1
- import { promises as fs } from 'node:fs';
2
- import { Optimization } from './library.js';
1
+ import {promises as fs} from 'node:fs';
2
+ import {Optimization} from './library.js';
3
3
 
4
4
  /**
5
5
  * @namespace FunctionRegistry
@@ -1,81 +1,146 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint;
6
-
7
- /**
8
- * Intégration directe du moteur de fingerprinting pour les applications PHP sans framework PSR.
9
- * Cette classe interagit directement avec les superglobales PHP et les fonctions de réponse.
10
- */
11
- class DirectFingerprint
12
- {
13
- private FingerprintEngine $engine;
14
-
15
- /**
16
- * @param array $securityConfig La configuration de sécurité pour le moteur.
17
- */
18
- public function __construct(array $securityConfig)
19
- {
20
- $this->engine = new FingerprintEngine($securityConfig);
21
- }
22
-
23
- /**
24
- * Protège le point d'entrée actuel.
25
- * Analyse la requête entrante et, si nécessaire, envoie une réponse de challenge/blocage et termine le script.
26
- * Si la requête est autorisée, la méthode retourne simplement et le reste du script peut s'exécuter.
27
- *
28
- * @return array{score: float, vector: array}|null Les données du fingerprint si la requête est autorisée, null sinon.
29
- */
30
- public function protect(): ?array
31
- {
32
- // 1. Construire le contexte de la requête à partir des superglobales PHP.
33
- $body = $_POST ?: json_decode(file_get_contents('php://input'), true);
34
- $headers = function_exists('getallheaders') ? getallheaders() : [];
35
-
36
- $context = new RequestContext(
37
- $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
38
- parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '/',
39
- $headers,
40
- $_GET,
41
- $body,
42
- $_COOKIE,
43
- $_SERVER['SERVER_PROTOCOL'] ?? '1.1'
44
- );
45
-
46
- // 2. Traiter la requête avec le moteur.
47
- $decision = $this->engine->processRequest($context);
48
-
49
- // 3. Agir sur la décision.
50
- if (isset($context->newCookieForResponse)) {
51
- $cookie = $context->newCookieForResponse;
52
- setcookie($cookie['name'], $cookie['value'], $cookie['options']);
53
- }
54
-
55
- switch ($decision['action']) {
56
- case 'block':
57
- case 'challenge':
58
- http_response_code($decision['status'] ?? 403);
59
- if (is_array($decision['body'])) {
60
- header('Content-Type: application/json');
61
- echo json_encode($decision['body']);
62
- } else {
63
- header('Content-Type: text/html; charset=utf-8');
64
- echo $decision['body'];
65
- }
66
- exit(); // Termine le script.
67
-
68
- case 'redirect':
69
- if (isset($decision['cookie'])) {
70
- setcookie($decision['cookie']['name'], $decision['cookie']['value'], $decision['cookie']['options']);
71
- }
72
- header('Location: ' . $decision['path'], true, 302);
73
- exit(); // Termine le script.
74
-
75
- case 'next':
76
- default:
77
- // La requête est autorisée, on retourne les informations du fingerprint.
78
- return ['score' => $decision['score'], 'vector' => $decision['vector']];
79
- }
80
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Utils\MetricsManager;
8
+
9
+ /**
10
+ * Intégration directe du moteur de fingerprinting pour les applications PHP sans framework PSR.
11
+ * Cette classe interagit directement avec les superglobales PHP et les fonctions de réponse.
12
+ */
13
+ class DirectFingerprint
14
+ {
15
+ private array $securityConfig;
16
+ private FingerprintEngine $engine;
17
+
18
+ /**
19
+ * @param array $securityConfig La configuration de sécurité pour le moteur.
20
+ */
21
+ public function __construct(array $securityConfig)
22
+ {
23
+ $this->engine = new FingerprintEngine($securityConfig);
24
+ $this->securityConfig = $securityConfig;
25
+ }
26
+
27
+ /**
28
+ * Protège le point d'entrée actuel.
29
+ * Analyse la requête entrante et, si nécessaire, envoie une réponse de challenge/blocage et termine le script.
30
+ * Si la requête est autorisée, la méthode retourne simplement et le reste du script peut s'exécuter.
31
+ *
32
+ * @return array{score: float, vector: array}|null Les données du fingerprint si la requête est autorisée, null sinon.
33
+ */
34
+ public function protect(): ?array
35
+ {
36
+ // 1. Construire le contexte de la requête à partir des superglobales PHP.
37
+ $body = $_POST ?: json_decode(file_get_contents('php://input'), true);
38
+ $headers = function_exists('getallheaders') ? getallheaders() : [];
39
+
40
+ $context = new RequestContext(
41
+ $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
42
+ parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '/',
43
+ $headers,
44
+ $_GET,
45
+ $body,
46
+ $_COOKIE,
47
+ $_SERVER['SERVER_PROTOCOL'] ?? '1.1'
48
+ );
49
+
50
+ // 2. Traiter la requête avec le moteur.
51
+ $decision = $this->engine->processRequest($context);
52
+
53
+ // 3. Agir sur la décision.
54
+ if (isset($context->newCookieForResponse)) {
55
+ $cookie = $context->newCookieForResponse;
56
+ setcookie($cookie['name'], $cookie['value'], $cookie['options']);
57
+ }
58
+
59
+ switch ($decision['action']) {
60
+ case 'block':
61
+ case 'challenge':
62
+ http_response_code($decision['status'] ?? 403);
63
+ if (is_array($decision['body'])) {
64
+ header('Content-Type: application/json');
65
+ echo json_encode($decision['body']);
66
+ } else {
67
+ header('Content-Type: text/html; charset=utf-8');
68
+ echo $decision['body'];
69
+ }
70
+ exit(); // Termine le script.
71
+
72
+ case 'redirect':
73
+ if (isset($decision['cookie'])) {
74
+ setcookie($decision['cookie']['name'], $decision['cookie']['value'], $decision['cookie']['options']);
75
+ }
76
+ header('Location: ' . $decision['path'], true, 302);
77
+ exit(); // Termine le script.
78
+
79
+ case 'next':
80
+ default:
81
+ // La requête est autorisée, on retourne les informations du fingerprint.
82
+ return ['score' => $decision['score'], 'vector' => $decision['vector']];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Handles a request to the /metrics endpoint, applying authorization rules.
88
+ * If metrics are enabled and authorized, it outputs Prometheus formatted metrics and exits.
89
+ * Otherwise, it handles unauthorized access or returns a 404 if metrics are not enabled.
90
+ *
91
+ * @param RequestContext $context The current request context.
92
+ */
93
+ public function handleMetricsRequest(RequestContext $context): void
94
+ {
95
+ // 2. Appliquer le callback d'autorisation personnalisé si défini.
96
+ $authorizationCallback = $this->securityConfig['metricsAuthorizationCallback'] ?? null;
97
+ if (is_callable($authorizationCallback)) {
98
+ $decision = call_user_func($authorizationCallback, $context);
99
+
100
+ if (is_bool($decision)) {
101
+ if (!$decision) {
102
+ http_response_code(403); // Forbidden
103
+ echo "Access to metrics denied.";
104
+ exit();
105
+ }
106
+ } elseif (is_array($decision) && isset($decision['action'])) {
107
+ switch ($decision['action']) {
108
+ case 'block':
109
+ http_response_code($decision['status'] ?? 403);
110
+ echo $decision['body'] ?? "Access denied.";
111
+ exit();
112
+ case 'redirect':
113
+ header('Location: ' . $decision['path'], true, $decision['status'] ?? 302);
114
+ exit();
115
+ case 'next':
116
+ // Autorisé, continuer pour servir les métriques
117
+ break;
118
+ default:
119
+ // Action inconnue, refuser par défaut
120
+ http_response_code(403);
121
+ echo "Invalid authorization decision.";
122
+ exit();
123
+ }
124
+ } else {
125
+ // Retour inattendu du callback, refuser par défaut
126
+ http_response_code(403);
127
+ echo "Invalid authorization callback response.";
128
+ exit();
129
+ }
130
+ }
131
+
132
+ // 3. Si autorisé, servir les métriques.
133
+ header('Content-Type: text/plain; version=0.0.4; charset=utf-8');
134
+ echo MetricsManager::getPrometheusMetrics();
135
+ exit();
136
+ }
137
+
138
+ /**
139
+ * Returns Prometheus formatted metrics if enabled in the security configuration.
140
+ * @return string|null
141
+ */
142
+ public function getPrometheusMetrics(): ?string
143
+ {
144
+ return MetricsManager::getPrometheusMetrics();
145
+ }
81
146
  }
@@ -4,12 +4,15 @@
4
4
 
5
5
  namespace Anonympins\Fingerprint;
6
6
 
7
- use Anonympins\Fingerprint\Config\SecurityProfiles;
8
- use Anonympins\Fingerprint\Store\StoreManager; // Correction de l'import
9
7
  use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
9
+ use Anonympins\Fingerprint\Store\StoreManager;
10
10
  use Anonympins\Fingerprint\Utils\BlockList;
11
11
  use Anonympins\Fingerprint\Utils\Logger;
12
- use Anonympins\Fingerprint\Utils\RequestUtils;
12
+ use Anonympins\Fingerprint\Utils\MetricsManager;
13
+ use Anonympins\Fingerprint\Utils\RequestUtils;
14
+
15
+ // Correction de l'import
13
16
 
14
17
  /**
15
18
  * Le moteur principal de la bibliothèque de fingerprinting.
@@ -525,6 +528,7 @@
525
528
  // 1. Vérifier les listes blanches
526
529
  if ($this->checkAllowlists($context)) {
527
530
  return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
531
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
528
532
  }
529
533
 
530
534
  // Initialiser le vecteur de suspicion
@@ -602,6 +606,7 @@
602
606
  if ($isValid) {
603
607
  $store->delete("secret:{$powNonce}");
604
608
  $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
609
+ MetricsManager::incrementCounter('challenges_solved_total');
605
610
  $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
606
611
 
607
612
  return [
@@ -618,6 +623,7 @@
618
623
  }
619
624
  }
620
625
  // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
626
+ MetricsManager::incrementCounter('challenges_failed_total');
621
627
  $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
622
628
  $suspicionVector['honeypotScore'] = 100.0;
623
629
  }
@@ -627,6 +633,7 @@
627
633
  $powCookie = $context->cookies['pow_clearance'] ?? null;
628
634
  if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie)) {
629
635
  $hasValidTicket = true;
636
+ MetricsManager::incrementCounter('tickets_valid_total');
630
637
  // On ne retourne pas tout de suite pour permettre le re-challenge
631
638
  // $this->log('Valid clearance ticket found');
632
639
  // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
@@ -660,6 +667,7 @@
660
667
  // Mettre à jour les métriques du sous-réseau après le calcul du score final
661
668
  if ($finalScore > ($thresholds['low'] ?? 20)) {
662
669
  RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
670
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
663
671
  }
664
672
 
665
673
  // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
@@ -695,6 +703,7 @@
695
703
  $blockThreshold = $thresholds['block'] ?? 95;
696
704
  if ($finalScore >= $blockThreshold) {
697
705
  if ($this->logger) {
706
+ MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
698
707
  $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
699
708
  }
700
709
  $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
@@ -703,6 +712,7 @@
703
712
  $decision['intendedAction'] = $decision['action'];
704
713
  $decision['action'] = 'next';
705
714
  unset($decision['status'], $decision['body']);
715
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
706
716
  }
707
717
  $response = $decision;
708
718
  } else {
@@ -715,6 +725,7 @@
715
725
  $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
716
726
  // Apply dry run logic here as well
717
727
  if ($this->dryRun) {
728
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
718
729
  $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
719
730
  $decision['intendedAction'] = $decision['action'];
720
731
  $decision['action'] = 'next';
@@ -735,6 +746,7 @@
735
746
  $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
736
747
 
737
748
  if ($this->dryRun) {
749
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
738
750
  $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
739
751
  $decision['intendedAction'] = $decision['action'];
740
752
  $decision['action'] = 'next';
@@ -742,6 +754,7 @@
742
754
  return $decision;
743
755
  }
744
756
 
757
+ MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
745
758
  $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
746
759
 
747
760
  $nonce = bin2hex(random_bytes(16));
@@ -853,12 +866,15 @@
853
866
  $response = $decision;
854
867
  } elseif ($hasValidTicket) {
855
868
  // Si on arrive ici avec un ticket valide et un score bas, on autorise
869
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
856
870
  $this->log('Valid clearance ticket found and score is low - allowing request');
857
871
  $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
858
872
  } else {
859
873
  // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
874
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
860
875
  $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
861
876
  if ($this->logger) {
877
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
862
878
  $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
863
879
  }
864
880
  $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
@@ -4,9 +4,9 @@ declare(strict_types=1);
4
4
 
5
5
  namespace Anonympins\Fingerprint;
6
6
 
7
- use Anonympins\Fingerprint\Store\IStore;
8
7
  use Anonympins\Fingerprint\Optimization\FunctionRegistry;
9
8
  use Anonympins\Fingerprint\Optimization\ProblemInitializers;
9
+ use Anonympins\Fingerprint\Store\IStore;
10
10
 
11
11
  class ProblemManager
12
12
  {