@anonympins/fingerprint 0.7.0 → 0.7.2

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 (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/composer.json +8 -1
  4. package/package.json +1 -1
  5. package/public/fingerprint-wordpress.zip +0 -0
  6. package/src/js/build-client.js +8 -10
  7. package/src/js/fingerprint.builder.js +37 -38
  8. package/src/js/fingerprint.client.js +1531 -1463
  9. package/src/js/fingerprint.client.obfuscated.js +1 -0
  10. package/src/js/fingerprint.js +190 -16
  11. package/src/js/fingerprint.utils.js +213 -213
  12. package/src/js/library.js +340 -376
  13. package/src/js/mongodb-store.js +1 -1
  14. package/src/js/optimization.worker.js +27 -27
  15. package/src/js/pow.solver.inline.js +154 -56
  16. package/src/js/pow.solver.js +180 -92
  17. package/src/js/pow.worker.js +48 -48
  18. package/src/js/redis-store.js +1 -1
  19. package/src/js/tests/fingerprint.client.init.test.js +146 -143
  20. package/src/js/tests/fingerprint.test.js +124 -10
  21. package/src/js/tests/ip-reputation.test.js +1 -0
  22. package/src/js/upow-model-task.js +14 -15
  23. package/src/php/AutoTuner.php +34 -34
  24. package/src/php/Challenge/ChallengeUtils.php +57 -11
  25. package/src/php/Config/SecurityProfiles.php +20 -0
  26. package/src/php/DirectFingerprint.php +190 -145
  27. package/src/php/FingerprintBuilder.php +29 -30
  28. package/src/php/FingerprintClient.php +147 -148
  29. package/src/php/FingerprintEngine.php +44 -17
  30. package/src/php/Ja3AnomalyDetector.php +33 -32
  31. package/src/php/Optimization/FunctionRegistry.php +7 -7
  32. package/src/php/Optimization/Optimization.php +24 -24
  33. package/src/php/Optimization/OptimizationOperators.php +26 -25
  34. package/src/php/Optimization/ProblemInitializers.php +52 -52
  35. package/src/php/ProblemManager.php +31 -34
  36. package/src/php/RequestContext.php +8 -1
  37. package/src/php/Store/IStore.php +7 -8
  38. package/src/php/Store/InMemoryStore.php +66 -66
  39. package/src/php/Store/MongoDbStore.php +5 -5
  40. package/src/php/Store/RedisStore.php +5 -5
  41. package/src/php/Store/StoreManager.php +35 -35
  42. package/src/php/Tests/ChallengeUtilsTest.php +453 -453
  43. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  44. package/src/php/Tests/FingerprintEngineTest.php +38 -0
  45. package/src/php/Tests/IpReputationTest.php +10 -0
  46. package/src/php/Tests/MaliciousPatternsTest.php +103 -103
  47. package/src/php/Tests/MetricsTest.php +97 -97
  48. package/src/php/Tests/ProblemManagerTest.php +376 -376
  49. package/src/php/Tests/QuicFingerprintTest.php +53 -53
  50. package/src/php/Tests/RequestUtilsTest.php +471 -471
  51. package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
  52. package/src/php/Tests/config/ed25519_key.json +3 -3
  53. package/src/php/Utils/BlockList.php +99 -99
  54. package/src/php/Utils/Env.php +49 -49
  55. package/src/php/Utils/MaliciousPatterns.php +74 -74
  56. package/src/php/Utils/MetricsManager.php +209 -209
  57. package/src/php/Utils/RequestUtils.php +129 -7
  58. package/src/php/Utils/TLSClientHelloParser.php +369 -369
  59. package/src/php/WordPress/WpDbStore.php +155 -0
  60. package/src/php/WordPress/fingerprint-wordpress.php +554 -0
  61. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  62. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +262 -0
  63. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  64. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +268 -0
  65. package/src/php/WordPress/package.php +218 -0
  66. package/src/php/bin/auto-tune.php +118 -118
@@ -2347,7 +2347,9 @@ function getBehaviorScore(context) {
2347
2347
  if (metrics.honeypotInteraction) {
2348
2348
  return { behaviorScore: 100 };
2349
2349
  }
2350
-
2350
+ if (metrics.prototypeTampered) {
2351
+ score += 80; // Altération flagrante de l'environnement JS
2352
+ }
2351
2353
  // 2. Analyse des mouvements de la souris
2352
2354
  const { avgSpeed, avgAcceleration, straightness, pauses, segments } = analyzeMouseMovements(metrics.mouseMovementsHistory);
2353
2355
  const touchAnalysis = analyzeTouchMovements(metrics.touchMovementsHistory);
@@ -2399,6 +2401,14 @@ function getBehaviorScore(context) {
2399
2401
  const benfordDev = Optimization.Operators.benfordTest(touch.segments);
2400
2402
  if (benfordDev > 0.18) score += 35;
2401
2403
  }
2404
+
2405
+ // Détection de ferme mobile : Touch actif sur mobile sans aucune vibration physique (châssis/rack ADB)
2406
+ const isMobileDevice = (context.headers['user-agent'] || '').includes('Mobile');
2407
+ if (isMobileDevice && touchHistory.length >= 5 && typeof metrics.motionVariance === 'number') {
2408
+ if (metrics.motionVariance === 0) {
2409
+ score += 50; // Terminal fixé sur un châssis mécanique (rack ADB)
2410
+ }
2411
+ }
2402
2412
  }
2403
2413
 
2404
2414
  // Plausibilité de la latence de frappe
@@ -3032,6 +3042,62 @@ function getIpSubnet(ip, ipv4Prefix = 24, ipv6Prefix = 48) { // eslint-disable-l
3032
3042
  return null;
3033
3043
  }
3034
3044
 
3045
+ /**
3046
+ * Calcule la longueur du préfixe commun (en bits) entre deux adresses IP (IPv4 ou IPv6).
3047
+ * @param {string} ip1
3048
+ * @param {string} ip2
3049
+ * @returns {number} Nombre de bits identiques en tête (0 à 32 pour IPv4, 0 à 128 pour IPv6).
3050
+ */
3051
+ function getIpCommonPrefixLength(ip1, ip2) {
3052
+ if (!ip1 || !ip2) return 0;
3053
+ if (isIPv4(ip1) && isIPv4(ip2)) {
3054
+ const b1 = ip1.split('.').map(Number);
3055
+ const b2 = ip2.split('.').map(Number);
3056
+ const int1 = ((b1[0] << 24) | (b1[1] << 16) | (b1[2] << 8) | b1[3]) >>> 0;
3057
+ const int2 = ((b2[0] << 24) | (b2[1] << 16) | (b2[2] << 8) | b2[3]) >>> 0;
3058
+ const xor = (int1 ^ int2) >>> 0;
3059
+ return xor === 0 ? 32 : Math.clz32(xor);
3060
+ }
3061
+ if (isIPv6(ip1) && isIPv6(ip2)) {
3062
+ const normalize = (ip) => {
3063
+ let normalized = ip.trim().toLowerCase();
3064
+ if (normalized.includes("::")) {
3065
+ const parts = normalized.split("::");
3066
+ const left = parts[0] ? parts[0].split(":") : [];
3067
+ const right = parts[1] ? parts[1].split(":") : [];
3068
+ const missing = 8 - (left.length + right.length);
3069
+ const middle = Array(missing).fill("0000");
3070
+ normalized = [...left, ...middle, ...right].join(":");
3071
+ } else {
3072
+ const parts = normalized.split(":");
3073
+ if (parts.length !== 8) return null;
3074
+ }
3075
+ return normalized.split(":").map(g => parseInt(g, 16) || 0);
3076
+ };
3077
+
3078
+ try {
3079
+ const g1 = normalize(ip1);
3080
+ const g2 = normalize(ip2);
3081
+ if (!g1 || !g2) return 0;
3082
+ let prefix = 0;
3083
+ for (let i = 0; i < 8; i++) {
3084
+ const xor = (g1[i] ^ g2[i]) & 0xffff;
3085
+ if (xor === 0) {
3086
+ prefix += 16;
3087
+ } else {
3088
+ // clz32 opère sur des entiers 32 bits, ajuster pour 16 bits
3089
+ prefix += (Math.clz32(xor) - 16);
3090
+ break;
3091
+ }
3092
+ }
3093
+ return prefix;
3094
+ } catch (e) {
3095
+ return 0;
3096
+ }
3097
+ }
3098
+ return 0;
3099
+ }
3100
+
3035
3101
  /**
3036
3102
  * Applies temporal decay (half-life of 30 minutes) to subnet metrics.
3037
3103
  * @private
@@ -3079,6 +3145,11 @@ function decaySubnetData(subnetData, now) {
3079
3145
  subnetData.uas = subnetData.uas.slice(0, newLen);
3080
3146
  }
3081
3147
 
3148
+ if (Array.isArray(subnetData.attackerIps)) {
3149
+ const maxAttackerAgeMs = 60 * 60 * 1000; // 1 heure max
3150
+ subnetData.attackerIps = subnetData.attackerIps.filter(a => (now - (a.lastSeen || now)) < maxAttackerAgeMs);
3151
+ }
3152
+
3082
3153
  subnetData.lastActivity = now - (inactivityMs % (30 * 60 * 1000));
3083
3154
  }
3084
3155
  return subnetData;
@@ -3101,7 +3172,8 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
3101
3172
  highScoreDevices: {},
3102
3173
  lastActivity: 0,
3103
3174
  ips: [],
3104
- uas: []
3175
+ uas: [],
3176
+ attackerIps: []
3105
3177
  };
3106
3178
 
3107
3179
  if (!subnetData.highScoreDevices) {
@@ -3113,6 +3185,9 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
3113
3185
  subnetData.ips = [];
3114
3186
  }
3115
3187
  if (!subnetData.uas) subnetData.uas = [];
3188
+ if (!Array.isArray(subnetData.attackerIps)) {
3189
+ subnetData.attackerIps = [];
3190
+ }
3116
3191
 
3117
3192
  const now = Date.now();
3118
3193
  decaySubnetData(subnetData, now);
@@ -3136,6 +3211,20 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
3136
3211
  }
3137
3212
 
3138
3213
  const userAgent = context.headers?.['user-agent'] || '';
3214
+
3215
+ if (finalScore >= 70) {
3216
+ const existingAttacker = subnetData.attackerIps.find(a => a.ip === context.clientIp);
3217
+ if (existingAttacker) {
3218
+ existingAttacker.lastSeen = now;
3219
+ existingAttacker.score = Math.max(existingAttacker.score || 0, finalScore);
3220
+ } else {
3221
+ subnetData.attackerIps.push({ ip: context.clientIp, lastSeen: now, score: finalScore });
3222
+ if (subnetData.attackerIps.length > 30) {
3223
+ subnetData.attackerIps.shift();
3224
+ }
3225
+ }
3226
+ }
3227
+
3139
3228
  if (userAgent && !subnetData.uas.includes(userAgent)) {
3140
3229
  subnetData.uas.push(userAgent);
3141
3230
  }
@@ -3178,9 +3267,14 @@ export function calculateAnalogInconsistencyScore(consistencyScore, inflectionPo
3178
3267
  /**
3179
3268
  * Calculates a suspicion score based on the historical activity of the IP subnet.
3180
3269
  * @param {object} context The request context.
3270
+ * @param {string} [deviceId=''] The device ID.
3271
+ * @param {object} [securityConfig=null] Security configuration containing thresholds.
3181
3272
  * @returns {Promise<{subnetScore: number}>}
3182
3273
  */
3183
- async function getSubnetScore(context) {
3274
+ async function getSubnetScore(context, deviceId = '', securityConfig = null) {
3275
+ if (typeof deviceId === 'object' && deviceId !== null && !securityConfig) {
3276
+ securityConfig = deviceId;
3277
+ }
3184
3278
  const subnet = getIpSubnet(context.clientIp);
3185
3279
  if (!subnet) return { subnetScore: 0 };
3186
3280
  const key = `subnet:${subnet}`;
@@ -3223,14 +3317,61 @@ async function getSubnetScore(context) {
3223
3317
  const uaDispersion = Math.min(3.0, Math.max(1, uaCount) / deviceCount);
3224
3318
  const uaMultiplier = 0.7 + 0.3 * Math.tanh(uaDispersion - 1.0);
3225
3319
 
3226
- // 4. Intensité brute continue de la menace (sans discontinuité)
3320
+ // 4. Intensité brute continue de la menace
3227
3321
  const rawThreatIntensity = highScoreCount * bayesianDensity * ipMultiplier * uaMultiplier;
3228
3322
 
3229
- // 5. Saturation asymptotique continue (Asymptote à 99.9)
3230
- // Même si rawThreatIntensity tend vers l'infini, tanh converge vers 1.0 sans jamais dépasser 85.0
3231
- const ASYMPTOTE = 99.9;
3232
- const scaleFactor = 4.0; // Sensibilité de la transition
3233
- const finalScore = Math.round(ASYMPTOTE * Math.tanh(rawThreatIntensity / scaleFactor) * 10) / 10;
3323
+ // 5. Composante 1 : Score ambiant plafonné en zone Medium (asymptote au seuil medium)
3324
+ const mediumThreshold = (securityConfig?.thresholds?.medium !== undefined)
3325
+ ? Number(securityConfig.thresholds.medium)
3326
+ : 45.0;
3327
+ const ambientAsymptote = mediumThreshold;
3328
+ const maxProximityBoost = Math.max(0.0, 100.0 - ambientAsymptote);
3329
+ const scaleFactor = 10.0;
3330
+ const ambientScore = ambientAsymptote * Math.tanh(rawThreatIntensity / scaleFactor);
3331
+
3332
+ // 6. Composante 2 : Boost de proximité micro-réseau avec des attaquants récents (le reste va jusqu'à 100)
3333
+ let proximityBoost = 0.0;
3334
+ const attackerIps = subnetData.attackerIps || [];
3335
+ const clientIp = context.clientIp;
3336
+
3337
+ if (attackerIps.length > 0 && clientIp) {
3338
+ let maxCommonPrefix = 0;
3339
+ const isClientV4 = isIPv4(clientIp);
3340
+ const isClientV6 = isIPv6(clientIp);
3341
+
3342
+ for (const att of attackerIps) {
3343
+ if (att.ip && (now - (att.lastSeen || now)) <= 30 * 60 * 1000) {
3344
+ const prefixLen = getIpCommonPrefixLength(clientIp, att.ip);
3345
+ if (prefixLen > maxCommonPrefix) {
3346
+ maxCommonPrefix = prefixLen;
3347
+ }
3348
+ }
3349
+ }
3350
+
3351
+ if (isClientV4) {
3352
+ if (maxCommonPrefix >= 32) {
3353
+ proximityBoost = maxProximityBoost; // Même adresse IP exacte
3354
+ } else if (maxCommonPrefix >= 30) {
3355
+ proximityBoost = maxProximityBoost * (45.0 / 55.0); // Même /30 (écart <= 3 adresses)
3356
+ } else if (maxCommonPrefix >= 28) {
3357
+ proximityBoost = maxProximityBoost * (30.0 / 55.0); // Même /28 (bloc de 16 adresses)
3358
+ } else if (maxCommonPrefix >= 26) {
3359
+ proximityBoost = maxProximityBoost * (15.0 / 55.0); // Même /26 (bloc de 64 adresses)
3360
+ }
3361
+ } else if (isClientV6) {
3362
+ if (maxCommonPrefix >= 128) {
3363
+ proximityBoost = maxProximityBoost; // Même IPv6 exacte
3364
+ } else if (maxCommonPrefix >= 120) {
3365
+ proximityBoost = maxProximityBoost * (45.0 / 55.0); // Même /120
3366
+ } else if (maxCommonPrefix >= 112) {
3367
+ proximityBoost = maxProximityBoost * (30.0 / 55.0); // Même /112
3368
+ } else if (maxCommonPrefix >= 96) {
3369
+ proximityBoost = maxProximityBoost * (15.0 / 55.0); // Même /96
3370
+ }
3371
+ }
3372
+ }
3373
+
3374
+ const finalScore = Math.min(100.0, Math.round((ambientScore + proximityBoost) * 10) / 10);
3234
3375
 
3235
3376
  return { subnetScore: finalScore };
3236
3377
  }
@@ -3809,7 +3950,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
3809
3950
  getBehavioralIndicators(context, deviceData), // This modifies deviceData, so it must be done before saving deviceData
3810
3951
  getThreatIntelScore(context, zkpY), // NOUVEAU: Score de Threat Intelligence Fédéré
3811
3952
  getTlsSpoofingScore(context),
3812
- getSubnetScore(context, deviceId),
3953
+ getSubnetScore(context, deviceId, securityConfig),
3813
3954
  getIpReputationScore(clientIp),
3814
3955
  getBotnetClusterScore(context, stableFpHash),
3815
3956
  store.set(`ip-device:${clientIp}`, deviceId, 600) // Link the IP to the device for 10 minutes
@@ -4331,6 +4472,17 @@ export class FingerprintEngine {
4331
4472
  }
4332
4473
  }
4333
4474
  }
4475
+
4476
+ // If no security config is provided at all, create a base one.
4477
+ if (!finalConfig) {
4478
+ finalConfig = {};
4479
+ }
4480
+ // If the whitelist is not explicitly provided in the configuration,
4481
+ // apply the default whitelist which includes common search engine bots.
4482
+ if (!finalConfig.whitelist) {
4483
+ finalConfig.whitelist = default_whitelist();
4484
+ }
4485
+
4334
4486
  this.securityConfig = finalConfig;
4335
4487
  this.isProduction = isProduction;
4336
4488
  this._allowlist = this._buildAllowlist();
@@ -4529,7 +4681,8 @@ export class FingerprintEngine {
4529
4681
  return false;
4530
4682
  }
4531
4683
  _isIpInAllowlist(clientIp) {
4532
- return this._allowlist.check(clientIp);
4684
+ const family = isIPv6(clientIp) ? 'ipv6' : 'ipv4';
4685
+ return this._allowlist.check(clientIp, family);
4533
4686
  }
4534
4687
  /**
4535
4688
  * Checks if the request's host and path match an entry in the host+path allowlist.
@@ -4668,7 +4821,10 @@ export class FingerprintEngine {
4668
4821
  return false;
4669
4822
  }
4670
4823
 
4824
+ const botName = matchedRule.userAgent; // e.g., 'Googlebot'
4825
+
4671
4826
  if (!canAttemptDns()) {
4827
+ this._log(`[Bot Verification] DNS circuit breaker is open. Skipping check for ${botName}.`, { clientIp });
4672
4828
  return false;
4673
4829
  }
4674
4830
 
@@ -4676,9 +4832,11 @@ export class FingerprintEngine {
4676
4832
  const cachedStatus = await store.get(cacheKey);
4677
4833
 
4678
4834
  if (cachedStatus === 'verified') {
4835
+ this._log(`[Bot Verification] PASSED (cached): ${botName}`, { clientIp });
4679
4836
  return true;
4680
4837
  }
4681
4838
  if (cachedStatus === 'failed') {
4839
+ this._log(`[Bot Verification] FAILED (cached): ${botName}`, { clientIp });
4682
4840
  return false;
4683
4841
  }
4684
4842
 
@@ -4688,25 +4846,35 @@ export class FingerprintEngine {
4688
4846
  const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
4689
4847
 
4690
4848
  if (!validHostname) {
4849
+ this._log(`[Bot Verification] FAILED: Reverse DNS lookup for ${clientIp} did not yield a valid hostname ending in '${matchedRule.hostnameSuffix}'.`, { clientIp, resolvedHostnames: hostnames });
4691
4850
  await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
4692
4851
  return false;
4693
4852
  }
4694
4853
 
4854
+ this._log(`[Bot Verification] Reverse DNS OK for ${botName}.`, { clientIp, validHostname });
4855
+
4695
4856
  // 2. Forward DNS lookup
4696
4857
  let addresses = [];
4697
4858
  try {
4698
4859
  addresses = await withTimeout(dns.resolve(validHostname), 500);
4699
- } catch (e) {}
4860
+ } catch (e) {
4861
+ this._log(`[Bot Verification] Forward DNS (A) lookup FAILED for ${validHostname}.`, { error: e.message });
4862
+ }
4700
4863
  try {
4701
4864
  const ipv6 = await withTimeout(dns.resolve(validHostname, 'AAAA'), 500);
4702
4865
  addresses = addresses.concat(ipv6);
4703
- } catch (e) {}
4866
+ } catch (e) {
4867
+ this._log(`[Bot Verification] Forward DNS (AAAA) lookup FAILED for ${validHostname}.`, { error: e.message });
4868
+ }
4704
4869
  if (addresses.includes(clientIp)) {
4870
+ this._log(`[Bot Verification] PASSED: Forward DNS IP matches original IP for ${botName}.`, { clientIp, validHostname, resolvedIps: addresses });
4705
4871
  recordDnsSuccess();
4706
4872
  await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
4707
4873
  return true;
4708
4874
  }
4875
+ this._log(`[Bot Verification] FAILED: IP mismatch for ${botName}. Original IP not in resolved addresses.`, { clientIp, validHostname, resolvedIps: addresses });
4709
4876
  } catch (error) {
4877
+ this._log(`[Bot Verification] FAILED: DNS lookup error for ${botName}.`, { clientIp, error: error.message });
4710
4878
  recordDnsFailure();
4711
4879
  await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
4712
4880
  return false;
@@ -4824,15 +4992,15 @@ export class FingerprintEngine {
4824
4992
  }
4825
4993
  }
4826
4994
 
4827
- // Check if the request is from a verified, whitelisted bot (e.g., Googlebot)
4828
- if (await this._verifyWhitelistedBot(requestContext)) {
4995
+ const botVerificationResult = await this._verifyWhitelistedBot(requestContext);
4996
+ if (botVerificationResult === true) {
4829
4997
  const filterWhitelist = this.securityConfig.filterWhitelist || false;
4830
4998
  let bypassWhitelist = false;
4831
4999
  if (filterWhitelist === true) {
4832
5000
  bypassWhitelist = this._hasCertainAttack(requestContext);
4833
5001
  } else if (typeof filterWhitelist === 'number') {
4834
5002
  const res = await getScoreAndVector();
4835
- if (res.score > filterWhitelist) {
5003
+ if (res.score > filterWhitelist) { // NOSONAR
4836
5004
  bypassWhitelist = true;
4837
5005
  }
4838
5006
  }
@@ -4872,6 +5040,11 @@ export class FingerprintEngine {
4872
5040
 
4873
5041
  // The engine now works with the context directly, no more rawReq dependency here.
4874
5042
  const suspicionVector = preCalculatedVector || await __internal.getSuspicionVector(requestContext, this.securityConfig);
5043
+ if (botVerificationResult === false) {
5044
+ // This means it claimed to be a bot but failed verification.
5045
+ // This is a strong signal of spoofing.
5046
+ suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
5047
+ }
4875
5048
  let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
4876
5049
 
4877
5050
  this._log('Suspicion vector calculated', {
@@ -6774,6 +6947,7 @@ export const __internal = {
6774
6947
  generateCombinedPoWChallengePage,
6775
6948
  problemManager, // Re-export the problemManager promise
6776
6949
  getIpSubnet, // Expose for testing
6950
+ getIpCommonPrefixLength, // Expose for testing
6777
6951
  updateSubnetMetrics, // Expose for testing
6778
6952
  getSubnetScore, // Expose for testing
6779
6953
  getIpReputationScore, // Expose for testing