@anonympins/fingerprint 0.3.3 → 0.3.5

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.
@@ -0,0 +1,157 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\Utils\RequestUtils;
9
+ use Anonympins\Fingerprint\Store\StoreManager;
10
+ use Anonympins\Fingerprint\Store\IStore;
11
+ use Anonympins\Fingerprint\RequestContext;
12
+
13
+ class IpReputationTest extends TestCase
14
+ {
15
+ private $store;
16
+
17
+ protected function setUp(): void
18
+ {
19
+ parent::setUp();
20
+
21
+ // Un mock anonyme simple et en mémoire du Store pour isoler les tests
22
+ $this->store = new class implements IStore {
23
+ private array $data = [];
24
+ public function get(string $key) { return $this->data[$key] ?? null; }
25
+ public function set(string $key, $value, ?int $ttl = null): void { $this->data[$key] = $value; }
26
+ public function has(string $key): bool { return isset($this->data[$key]); }
27
+ public function delete(string $key): void { unset($this->data[$key]); }
28
+ };
29
+
30
+ StoreManager::setStore($this->store);
31
+ }
32
+
33
+ public function testGetScoreReturnsZeroForUnknownIp(): void
34
+ {
35
+ $score = RequestUtils::getIpReputationScore('8.8.8.8');
36
+ $this->assertEquals(0.0, $score);
37
+ }
38
+
39
+ public function testUpdateScoreChangesValueCorrectly(): void
40
+ {
41
+ $ip = '8.8.4.4';
42
+ RequestUtils::updateIpReputationScore($ip, 45.0);
43
+ $this->assertEquals(45.0, RequestUtils::getIpReputationScore($ip));
44
+
45
+ RequestUtils::updateIpReputationScore($ip, -15.0);
46
+ $this->assertEquals(30.0, RequestUtils::getIpReputationScore($ip));
47
+ }
48
+
49
+ public function testBoundsAreClampedBetween0And100(): void
50
+ {
51
+ $ip = '1.1.1.1';
52
+ RequestUtils::updateIpReputationScore($ip, 120.0);
53
+ $this->assertEquals(100.0, RequestUtils::getIpReputationScore($ip));
54
+
55
+ RequestUtils::updateIpReputationScore($ip, -150.0);
56
+ $this->assertEquals(0.0, RequestUtils::getIpReputationScore($ip));
57
+ }
58
+
59
+ public function testTimeDecayCalculatesLossOfTwoPointsPerHour(): void
60
+ {
61
+ $ip = '2.2.2.2';
62
+ $now = time();
63
+
64
+ // Simulation d'un score de 80 vieux de 4 heures (4 * 2 = 8 points de perte)
65
+ $this->store->set("ip-reputation:{$ip}", [
66
+ 'score' => 80.0,
67
+ 'lastUpdate' => $now - 14400
68
+ ]);
69
+
70
+ $score = RequestUtils::getIpReputationScore($ip);
71
+ $this->assertEquals(72.0, $score);
72
+ }
73
+
74
+ public function testIpReputationScoreIsIntegratedIntoFinalScore(): void
75
+ {
76
+ $ip = '1.2.3.4';
77
+ RequestUtils::updateIpReputationScore($ip, 60.0);
78
+
79
+ $weights = [
80
+ 'ipReputationScore' => 0.5,
81
+ 'historyScore' => 0.0,
82
+ 'rotationScore' => 0.0,
83
+ 'headerAnomalyScore' => 0.0,
84
+ 'requestPatternScore' => 0.0,
85
+ 'inconsistencyScore' => 0.0,
86
+ 'honeypotScore' => 0.0,
87
+ 'behaviorScore' => 0.0,
88
+ 'botScore' => 0.0,
89
+ 'crossLayerInconsistencyScore' => 0.0,
90
+ 'tlsSpoofingScore' => 0.0,
91
+ 'timeInconsistencyScore' => 0.0,
92
+ 'clickVarianceScore' => 0.0,
93
+ 'clientHintsInconsistencyScore' => 0.0,
94
+ 'subnetScore' => 0.0,
95
+ ];
96
+
97
+ $ipRepScore = RequestUtils::getIpReputationScore($ip);
98
+ $this->assertEquals(60.0, $ipRepScore);
99
+ $score = $ipRepScore * $weights['ipReputationScore'];
100
+ $this->assertEquals(30.0, $score);
101
+ }
102
+
103
+ public function testGetIpSubnetIpv4AndIpv6(): void
104
+ {
105
+ $ipv4Subnet = RequestUtils::getIpSubnet('192.168.1.50', 24, 48);
106
+ $this->assertEquals('192.168.1.0/24', $ipv4Subnet);
107
+
108
+ $ipv6Subnet = RequestUtils::getIpSubnet('2001:db8:abcd:0012::1', 24, 48);
109
+ $this->assertEquals('2001:db8:abcd::/48', $ipv6Subnet);
110
+ }
111
+
112
+ public function testGetClientHintsInconsistencyScoreMismatch(): void
113
+ {
114
+ $context = $this->createMock(RequestContext::class);
115
+ $context->method('getHeader')->willReturnCallback(function($name) {
116
+ if ($name === 'user-agent') {
117
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/117.0';
118
+ }
119
+ if ($name === 'sec-ch-ua') {
120
+ return '"Google Chrome";v="117"';
121
+ }
122
+ return null;
123
+ });
124
+
125
+ $score = RequestUtils::getClientHintsInconsistencyScore($context);
126
+ $this->assertEquals(90.0, $score['clientHintsInconsistencyScore']);
127
+ }
128
+
129
+ public function testGetSubnetScoreCalculations(): void
130
+ {
131
+ $context = $this->createMock(RequestContext::class);
132
+ $context->clientIp = '192.168.1.50';
133
+
134
+ $score = RequestUtils::getSubnetScore($context, 'device-1');
135
+ $this->assertEquals(0.0, $score['subnetScore']);
136
+
137
+ for ($i = 1; $i <= 12; $i++) {
138
+ RequestUtils::updateSubnetMetrics($context, "device-{$i}", 30.0);
139
+ }
140
+
141
+ $scoreWithHistory = RequestUtils::getSubnetScore($context, 'device-1');
142
+ $this->assertGreaterThan(0.0, $scoreWithHistory['subnetScore']);
143
+ }
144
+
145
+ public function testTlsSpoofingScoreWithJa4(): void
146
+ {
147
+ $context = $this->createMock(RequestContext::class);
148
+ $context->ja4 = 't13d1517h2_8daaf61527d5';
149
+ $context->ja3 = null;
150
+ $context->method('getHeader')->willReturnCallback(function($name) {
151
+ return $name === 'user-agent' ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/117.0' : null;
152
+ });
153
+
154
+ $score = RequestUtils::getTlsSpoofingScore($context);
155
+ $this->assertEquals(90.0, $score['tlsSpoofingScore']);
156
+ }
157
+ }
@@ -0,0 +1,180 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\Ja3AnomalyDetector;
9
+
10
+ /**
11
+ * Tests unitaires pour la classe Ja3AnomalyDetector.
12
+ */
13
+ class Ja3AnomalyDetectorTest extends TestCase
14
+ {
15
+ /**
16
+ * Teste le parsing d'une chaîne JA3 brute valide.
17
+ */
18
+ public function testParseJa3ValidString(): void
19
+ {
20
+ $ja3 = '771,4865-4866-4867,0-23-65281-10-11,29-23-24,0';
21
+ $parsed = Ja3AnomalyDetector::parseJa3($ja3);
22
+
23
+ $this->assertNotNull($parsed);
24
+ $this->assertSame(771, $parsed['tlsVersion']);
25
+ $this->assertSame([4865, 4866, 4867], $parsed['ciphers']);
26
+ $this->assertSame([0, 23, 65281, 10, 11], $parsed['extensions']);
27
+ $this->assertSame([29, 23, 24], $parsed['curves']);
28
+ $this->assertSame([0], $parsed['points']);
29
+ }
30
+
31
+ /**
32
+ * Teste le parsing avec des valeurs manquantes ou malformées.
33
+ */
34
+ public function testParseJa3InvalidString(): void
35
+ {
36
+ $this->assertNull(Ja3AnomalyDetector::parseJa3(''));
37
+ $this->assertNull(Ja3AnomalyDetector::parseJa3('771,4865,0-23')); // Moins de 5 parties
38
+ }
39
+
40
+ /**
41
+ * Teste la détection du mécanisme GREASE.
42
+ */
43
+ public function testHasGrease(): void
44
+ {
45
+ // Contient la valeur GREASE 2570 (0x0A0A)
46
+ $this->assertTrue(Ja3AnomalyDetector::hasGrease([4865, 2570, 4866]));
47
+ // Ne contient pas de valeur GREASE
48
+ $this->assertFalse(Ja3AnomalyDetector::hasGrease([4865, 4866, 4867]));
49
+ }
50
+
51
+ /**
52
+ * Teste l'extraction de la famille du User-Agent.
53
+ */
54
+ public function testGetBrowserFamily(): void
55
+ {
56
+ $this->assertSame('Chrome', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'));
57
+ $this->assertSame('Edge', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'));
58
+ $this->assertSame('Firefox', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0'));
59
+ $this->assertSame('Safari', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'));
60
+ $this->assertNull(Ja3AnomalyDetector::getBrowserFamily('curl/7.68.0'));
61
+ }
62
+
63
+ /**
64
+ * Teste la détection d'usurpation d'une bibliothèque connue (ex: Python/Go/curl).
65
+ */
66
+ public function testSpoofingLibraryDetected(): void
67
+ {
68
+ $pythonJa3 = '47344a349b75c4e82333475553b5f358'; // Signature Python dans DB
69
+ $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
70
+
71
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore($pythonJa3, null, $userAgent, 'HTTP/2');
72
+
73
+ // Devrait retourner un score élevé (90) car une signature python se fait passer pour Chrome
74
+ $this->assertEquals(90, $score);
75
+ }
76
+
77
+ /**
78
+ * Teste la détection d'une incohérence de navigateur (ex: signature Firefox avec UA Chrome).
79
+ */
80
+ public function testBrowserMismatchDetected(): void
81
+ {
82
+ $firefoxJa3 = 'b386946a5a586163c7c533636b45c355'; // Signature Firefox dans DB
83
+ $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
84
+
85
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore($firefoxJa3, null, $userAgent, 'HTTP/2');
86
+
87
+ $this->assertEquals(80, $score);
88
+ }
89
+
90
+ /**
91
+ * Teste la détection de stagnation de hash avec rotation d'User-Agents.
92
+ */
93
+ public function testStagnationWithRotatingUserAgents(): void
94
+ {
95
+ // Mock d'un stockage de cache minimal en mémoire
96
+ $cache = new class {
97
+ private array $store = [];
98
+ public function get(string $key): ?string { return $this->store[$key] ?? null; }
99
+ public function set(string $key, string $val, int $ttl = 0): void { $this->store[$key] = $val; }
100
+ public function setex(string $key, int $ttl, string $val): void { $this->store[$key] = $val; }
101
+ };
102
+
103
+ $unknownJa3 = '00000000000000000000000000000000';
104
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
105
+ $firefoxUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/120.0';
106
+
107
+ // Premier appel avec Chrome -> pas d'anomalie de stagnation encore
108
+ $score1 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $chromeUA, 'HTTP/2', $cache);
109
+ $this->assertLessThan(85, $score1);
110
+
111
+ // Deuxième appel avec la même stack TLS mais un UA Firefox -> Détection de rotation !
112
+ $score2 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $firefoxUA, 'HTTP/2', $cache);
113
+ $this->assertEquals(85, $score2);
114
+ }
115
+
116
+ /**
117
+ * Teste l'absence de valeurs GREASE pour un navigateur Chromium.
118
+ */
119
+ public function testMissingGreaseForChrome(): void
120
+ {
121
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
122
+ // JA3 brut valide mais sans AUCUNE valeur GREASE dans les extensions ou les ciphers
123
+ $ja3RawWithoutGrease = '771,4865-4866,0-23-10,29,0';
124
+
125
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithoutGrease, $chromeUA, 'HTTP/2');
126
+ $this->assertEquals(75, $score);
127
+ }
128
+
129
+ /**
130
+ * Teste le cas légitime d'un navigateur Chromium disposant de GREASE.
131
+ */
132
+ public function testLegitimateChromeWithGrease(): void
133
+ {
134
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
135
+ // 2570 est une valeur GREASE valide
136
+ $ja3RawWithGrease = '771,4865-2570,0-23-10,29,0';
137
+
138
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithGrease, $chromeUA, 'HTTP/2');
139
+ $this->assertLessThan(75, $score);
140
+ }
141
+
142
+ /**
143
+ * Teste la détection d'absence d'ALPN lors de connexions HTTP/2.
144
+ */
145
+ public function testHttp2WithoutAlpnExtension(): void
146
+ {
147
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
148
+ // Pas d'extension 16 (ALPN) dans les extensions
149
+ $ja3RawNoAlpn = '771,4865-2570,0-23-10,29,0';
150
+
151
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawNoAlpn, $chromeUA, 'HTTP/2');
152
+ $this->assertEquals(70, $score);
153
+ }
154
+
155
+ /**
156
+ * Teste la présence d'ALPN lors de connexions HTTP/2 (cas normal).
157
+ */
158
+ public function testHttp2WithAlpnExtension(): void
159
+ {
160
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
161
+ // L'extension 16 est présente
162
+ $ja3RawWithAlpn = '771,4865-2570,0-23-10-16,29,0';
163
+
164
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithAlpn, $chromeUA, 'HTTP/2');
165
+ $this->assertLessThan(70, $score);
166
+ }
167
+
168
+ /**
169
+ * Teste l'utilisation d'une version obsolète de TLS par un navigateur récent.
170
+ */
171
+ public function testObsoleteTlsVersionWithModernBrowser(): void
172
+ {
173
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
174
+ // Version TLS 769 (TLS 1.0) initiée par un Chrome récent
175
+ $obsoleteTlsJa3 = '769,4865-2570,0-23-10-16,29,0';
176
+
177
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $obsoleteTlsJa3, $chromeUA, 'HTTP/2');
178
+ $this->assertEquals(80, $score);
179
+ }
180
+ }
@@ -4,59 +4,88 @@ declare(strict_types=1);
4
4
 
5
5
  namespace Anonympins\Fingerprint\Utils;
6
6
 
7
- /**
8
- * Classe d'émulation pour les opérations sur de grands entiers (BigInt),
9
- * similaire au BigInt de JavaScript. Utilise l'extension GMP.
10
- * @internal
11
- */
12
7
  class BigInt
13
8
  {
14
- /** @var \GMP */
15
- private \GMP $gmp;
9
+ private static bool $useGmp;
10
+ /** @var \GMP|string */
11
+ private $value;
16
12
 
17
13
  /**
18
- * @param \GMP|string|int $number
14
+ * @param \GMP|string|int $number Le nombre initial.
19
15
  */
20
16
  public function __construct($number)
21
17
  {
22
- if ($number instanceof \GMP) {
23
- $this->gmp = $number;
18
+ if (!isset(self::$useGmp)) {
19
+ self::$useGmp = extension_loaded('gmp');
20
+ }
21
+
22
+ if (self::$useGmp) {
23
+ if ($number instanceof \GMP) {
24
+ $this->value = $number;
25
+ } else {
26
+ $this->value = gmp_init($number);
27
+ }
24
28
  } else {
25
- $this->gmp = gmp_init($number);
29
+ if ($number instanceof BigInt) {
30
+ $this->value = $number->value;
31
+ } else {
32
+ $this->value = (string)$number;
33
+ }
26
34
  }
27
35
  }
28
36
 
29
37
  /**
30
38
  * Crée une instance à partir d'une chaîne hexadécimale.
31
39
  */
32
- public static function fromHex(string $hex): self
40
+ public static function fromHex(string $hex): BigInt
33
41
  {
34
- return new self(gmp_init($hex, 16));
42
+ if (self::$useGmp) {
43
+ return new self(gmp_init($hex, 16));
44
+ } else {
45
+ $dec = '0';
46
+ $len = strlen($hex);
47
+ for ($i = 0; $i < $len; $i++) {
48
+ $dec = bcadd(bcmul($dec, '16'), (string)hexdec($hex[$i]));
49
+ }
50
+ return new self($dec);
51
+ }
35
52
  }
36
53
 
37
54
  /**
38
55
  * Compare cette instance avec une autre.
39
56
  * @return int < 0 si this < other, 0 si this == other, > 0 si this > other.
40
57
  */
41
- public function compareTo(self $other): int
58
+ public function compareTo(BigInt $other): int
42
59
  {
43
- return gmp_cmp($this->gmp, $other->gmp);
60
+ if (self::$useGmp) {
61
+ return gmp_cmp($this->value, $other->value);
62
+ } else {
63
+ return bccomp($this->value, $other->value);
64
+ }
44
65
  }
45
66
 
46
67
  /**
47
68
  * Effectue un décalage de bits vers la gauche (<<).
48
69
  */
49
- public function shiftLeft(int $bits): self
70
+ public function shiftLeft(int $bits): BigInt
50
71
  {
51
- return new self(gmp_mul($this->gmp, gmp_pow(2, $bits)));
72
+ if (self::$useGmp) {
73
+ return new self(gmp_mul($this->value, gmp_pow("2", $bits)));
74
+ } else {
75
+ return new self(bcmul($this->value, bcpow("2", (string)$bits)));
76
+ }
52
77
  }
53
78
 
54
79
  /**
55
80
  * Effectue un décalage de bits vers la droite (>>).
56
81
  */
57
- public function shiftRight(int $bits): self
82
+ public function shiftRight(int $bits): BigInt
58
83
  {
59
- return new self(gmp_div_q($this->gmp, gmp_pow(2, $bits)));
84
+ if (self::$useGmp) {
85
+ return new self(gmp_div_q($this->value, gmp_pow("2", $bits)));
86
+ } else {
87
+ return new self(bcdiv($this->value, bcpow("2", (string)$bits)));
88
+ }
60
89
  }
61
90
 
62
91
  /**
@@ -64,7 +93,11 @@ class BigInt
64
93
  */
65
94
  public function __toString(): string
66
95
  {
67
- return gmp_strval($this->gmp);
96
+ if (self::$useGmp) {
97
+ return gmp_strval($this->value);
98
+ } else {
99
+ return $this->value;
100
+ }
68
101
  }
69
102
 
70
103
  /**
@@ -72,31 +105,41 @@ class BigInt
72
105
  */
73
106
  public function toHex(): string
74
107
  {
75
- return gmp_strval($this->gmp, 16);
76
- }
77
-
78
- /**
79
- * Retourne la valeur GMP sous-jacente.
80
- * @return \GMP
81
- */
82
- public function getGmpValue(): \GMP
83
- {
84
- return $this->gmp;
108
+ if (self::$useGmp) {
109
+ return gmp_strval($this->value, 16);
110
+ } else {
111
+ $hex = '';
112
+ $dec = $this->value;
113
+ while (bccomp($dec, '0') > 0) {
114
+ $rem = bcmod($dec, '16');
115
+ $hex = dechex((int)$rem) . $hex;
116
+ $dec = bcdiv($dec, '16');
117
+ }
118
+ return $hex ?: '0';
119
+ }
85
120
  }
86
121
 
87
122
  /**
88
123
  * Crée une instance à partir d'une puissance de 2.
89
124
  */
90
- public static function pow(int $base, int $exp): self
125
+ public static function pow(int $base, int $exp): BigInt
91
126
  {
92
- return new self(gmp_pow($base, $exp));
127
+ if (self::$useGgmp) {
128
+ return new self(gmp_pow((string)$base, $exp));
129
+ } else {
130
+ return new self(bcpow((string)$base, (string)$exp));
131
+ }
93
132
  }
94
133
 
95
134
  /**
96
135
  * Soustrait un autre BigInt.
97
136
  */
98
- public function sub(self $other): self
137
+ public function sub(BigInt $other): BigInt
99
138
  {
100
- return new self(gmp_sub($this->gmp, $other->gmp));
139
+ if (self::$useGmp) {
140
+ return new self(gmp_sub($this->value, $other->value));
141
+ } else {
142
+ return new self(bcsub($this->value, $other->value));
143
+ }
101
144
  }
102
145
  }