@riavzon/bot-detector 1.0.0
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.
- package/LICENSE +202 -0
- package/README.md +512 -0
- package/dist/_data-sources/suffix.json +98 -0
- package/dist/chunk-C0xms8kb.cjs +34 -0
- package/dist/index.mjs +776 -0
- package/dist/index.mjs.map +1 -0
- package/dist/main.cjs +3236 -0
- package/dist/main.cjs.map +1 -0
- package/dist/main.d.cts +1606 -0
- package/dist/main.d.cts.map +1 -0
- package/dist/main.d.mts +1607 -0
- package/dist/main.d.mts.map +1 -0
- package/dist/main.mjs +3210 -0
- package/dist/main.mjs.map +1 -0
- package/dist/mysqlPoolConnector-9PtXF5E7.mjs +49 -0
- package/dist/mysqlPoolConnector-9PtXF5E7.mjs.map +1 -0
- package/dist/mysqlPoolConnector-D1eVEhRK.cjs +51 -0
- package/dist/mysqlPoolConnector-D1eVEhRK.cjs.map +1 -0
- package/package.json +104 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import { __askForUserAgent, __cache, __ensureMmdbctl, __restartData } from "@riavzon/shield-base/internal";
|
|
4
|
+
import { buildCitiesData, compiler, getBGPAndASN, getCrawlersIps, getGeoDatas, getJaDatabaseLmdb, getListOfProxies, getThreatLists, getTorLists, getUserAgentLmdbList } from "@riavzon/shield-base";
|
|
5
|
+
import consola from "consola";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import path$1 from "path";
|
|
10
|
+
import { createConfigManager } from "@riavzon/utils";
|
|
11
|
+
import z from "zod";
|
|
12
|
+
import "maxmind";
|
|
13
|
+
import "lmdb";
|
|
14
|
+
import { pino } from "pino";
|
|
15
|
+
import { existsSync, mkdirSync } from "fs";
|
|
16
|
+
import "unstorage";
|
|
17
|
+
import "unstorage/drivers/memory";
|
|
18
|
+
import "db0";
|
|
19
|
+
import { execSync } from "child_process";
|
|
20
|
+
|
|
21
|
+
//#region src/botDetector/db/findDataPath.ts
|
|
22
|
+
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
function getLibraryRoot(currentDir = __moduleDir) {
|
|
24
|
+
if (fs.existsSync(path.join(currentDir, "package.json"))) return currentDir;
|
|
25
|
+
const parentDir = path.resolve(currentDir, "..");
|
|
26
|
+
if (parentDir === currentDir) throw new Error("Could not find library root");
|
|
27
|
+
return getLibraryRoot(parentDir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/botDetector/cli/commands/start.ts
|
|
32
|
+
const startCommand = defineCommand({
|
|
33
|
+
meta: {
|
|
34
|
+
name: "Bot Detector",
|
|
35
|
+
description: "Get started with the installation wizard"
|
|
36
|
+
},
|
|
37
|
+
async run() {
|
|
38
|
+
const output = path$1.resolve(getLibraryRoot(), "_data-sources");
|
|
39
|
+
const sentinel = path$1.resolve(output, "asn.mmdb");
|
|
40
|
+
if (!process.stdout.isTTY) {
|
|
41
|
+
if (fs.existsSync(sentinel)) return;
|
|
42
|
+
consola.warn("bot-detector: data sources not found. Run `npx bot-detector init` in an interactive terminal to set up.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const cache = await __cache()._getCache() ?? {};
|
|
46
|
+
consola.start("Starting installation wizard...");
|
|
47
|
+
let mmdbPath = "";
|
|
48
|
+
if (cache.mmdbctlPath) mmdbPath = cache.mmdbctlPath;
|
|
49
|
+
else {
|
|
50
|
+
consola.start("Verifying system dependencies...");
|
|
51
|
+
mmdbPath = await __ensureMmdbctl();
|
|
52
|
+
cache.mmdbctlPath = mmdbPath;
|
|
53
|
+
}
|
|
54
|
+
let contactInfo = "";
|
|
55
|
+
if (cache.useragent) contactInfo = cache.useragent;
|
|
56
|
+
else {
|
|
57
|
+
contactInfo = await __askForUserAgent();
|
|
58
|
+
cache.useragent = contactInfo;
|
|
59
|
+
}
|
|
60
|
+
if (fs.existsSync(sentinel)) {
|
|
61
|
+
consola.success("Data sources already initialized. Run `bot-detector refresh` to update them.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
cache.selectedDataTypes = [
|
|
65
|
+
"BGP",
|
|
66
|
+
"City",
|
|
67
|
+
"Geography",
|
|
68
|
+
"Proxy",
|
|
69
|
+
"Tor",
|
|
70
|
+
"SEO",
|
|
71
|
+
"firehol_l1",
|
|
72
|
+
"firehol_l2",
|
|
73
|
+
"firehol_l3",
|
|
74
|
+
"firehol_l4",
|
|
75
|
+
"firehol_anonymous",
|
|
76
|
+
"JA4",
|
|
77
|
+
"UserAgent"
|
|
78
|
+
];
|
|
79
|
+
cache.outPutPath = output;
|
|
80
|
+
await __cache()._setCache(cache);
|
|
81
|
+
consola.start("Compiling all data sources...");
|
|
82
|
+
await Promise.all([
|
|
83
|
+
getBGPAndASN(contactInfo, output, mmdbPath),
|
|
84
|
+
buildCitiesData(output, mmdbPath),
|
|
85
|
+
getTorLists(output, mmdbPath),
|
|
86
|
+
getGeoDatas(output, mmdbPath),
|
|
87
|
+
getListOfProxies(output, mmdbPath),
|
|
88
|
+
getThreatLists(output, mmdbPath, true),
|
|
89
|
+
getCrawlersIps(output, mmdbPath),
|
|
90
|
+
getUserAgentLmdbList(output),
|
|
91
|
+
getJaDatabaseLmdb(output)
|
|
92
|
+
]);
|
|
93
|
+
consola.success("All data successfully compiled. You can now start using bot-detector.");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/botDetector/cli/commands/refresh.ts
|
|
99
|
+
const refreshData = defineCommand({
|
|
100
|
+
meta: {
|
|
101
|
+
name: "refresh",
|
|
102
|
+
description: "Refresh the local data sources the bot-detector use internally"
|
|
103
|
+
},
|
|
104
|
+
async run() {
|
|
105
|
+
const cache = await __cache()._getCache() ?? {};
|
|
106
|
+
if (!cache.outPutPath || !cache.mmdbctlPath || Object.keys(cache).length === 0) {
|
|
107
|
+
consola.error("No data to restart, please first run the installation wizard");
|
|
108
|
+
throw new Error();
|
|
109
|
+
}
|
|
110
|
+
const output = path$1.resolve(getLibraryRoot(), "_data-sources");
|
|
111
|
+
consola.start("Restarting data sources...");
|
|
112
|
+
await __restartData(output, true);
|
|
113
|
+
consola.success(`✨ All data successfully restarted!`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/botDetector/types/configSchema.ts
|
|
119
|
+
const dbConfigStorage = z.custom((val) => {
|
|
120
|
+
if (typeof val !== "object" || val === null) return false;
|
|
121
|
+
return typeof val.driver === "string";
|
|
122
|
+
}, { message: "Database config must be an object with a valid \"driver\" string" });
|
|
123
|
+
const store = z.object({ main: dbConfigStorage }).required().strict();
|
|
124
|
+
const cache = z.custom((val) => {
|
|
125
|
+
if (typeof val !== "object" || val === null) return false;
|
|
126
|
+
return typeof val.driver === "string";
|
|
127
|
+
}, { message: "Storage must be an object with a valid \"driver\" string" });
|
|
128
|
+
const configSchema = z.object({
|
|
129
|
+
store,
|
|
130
|
+
banScore: z.number().max(100).min(0).default(100),
|
|
131
|
+
maxScore: z.number().max(100).min(0).default(100),
|
|
132
|
+
restoredReputationPoints: z.number().default(10),
|
|
133
|
+
setNewComputedScore: z.boolean().default(false),
|
|
134
|
+
whiteList: z.array(z.union([
|
|
135
|
+
z.ipv4(),
|
|
136
|
+
z.ipv6(),
|
|
137
|
+
z.string()
|
|
138
|
+
])).optional().default([]),
|
|
139
|
+
checksTimeRateControl: z.object({
|
|
140
|
+
checkEveryRequest: z.boolean().default(true),
|
|
141
|
+
checkEvery: z.number().default(1e3 * 60 * 5)
|
|
142
|
+
}).prefault({}),
|
|
143
|
+
batchQueue: z.object({
|
|
144
|
+
flushIntervalMs: z.number().default(5e3),
|
|
145
|
+
maxBufferSize: z.number().default(100),
|
|
146
|
+
maxRetries: z.number().default(3)
|
|
147
|
+
}).prefault({}),
|
|
148
|
+
storage: cache.optional(),
|
|
149
|
+
checkers: z.object({
|
|
150
|
+
localeMapsCheck: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
151
|
+
enable: z.literal(true),
|
|
152
|
+
penalties: z.object({
|
|
153
|
+
ipAndHeaderMismatch: z.number().default(20),
|
|
154
|
+
missingHeader: z.number().default(20),
|
|
155
|
+
missingGeoData: z.number().default(20),
|
|
156
|
+
malformedHeader: z.number().default(30)
|
|
157
|
+
}).prefault({})
|
|
158
|
+
})]).prefault({ enable: true }),
|
|
159
|
+
knownBadUserAgents: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
160
|
+
enable: z.literal(true),
|
|
161
|
+
penalties: z.object({
|
|
162
|
+
criticalSeverity: z.number().default(100),
|
|
163
|
+
highSeverity: z.number().default(80),
|
|
164
|
+
mediumSeverity: z.number().default(30),
|
|
165
|
+
lowSeverity: z.number().default(10)
|
|
166
|
+
}).prefault({})
|
|
167
|
+
})]).prefault({ enable: true }),
|
|
168
|
+
enableIpChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
169
|
+
enable: z.literal(true),
|
|
170
|
+
penalties: z.number().default(10)
|
|
171
|
+
})]).prefault({ enable: true }),
|
|
172
|
+
enableGoodBotsChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
173
|
+
enable: z.literal(true),
|
|
174
|
+
banUnlistedBots: z.boolean().default(true),
|
|
175
|
+
penalties: z.number().default(100)
|
|
176
|
+
})]).prefault({ enable: true }),
|
|
177
|
+
enableBehaviorRateCheck: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
178
|
+
enable: z.literal(true),
|
|
179
|
+
behavioral_window: z.number().default(6e4),
|
|
180
|
+
behavioral_threshold: z.number().default(30),
|
|
181
|
+
penalties: z.number().default(60)
|
|
182
|
+
})]).prefault({ enable: true }),
|
|
183
|
+
enableProxyIspCookiesChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
184
|
+
enable: z.literal(true),
|
|
185
|
+
penalties: z.object({
|
|
186
|
+
cookieMissing: z.number().default(80),
|
|
187
|
+
proxyDetected: z.number().default(40),
|
|
188
|
+
multiSourceBonus2to3: z.number().default(10),
|
|
189
|
+
multiSourceBonus4plus: z.number().default(20),
|
|
190
|
+
hostingDetected: z.number().default(50),
|
|
191
|
+
ispUnknown: z.number().default(10),
|
|
192
|
+
orgUnknown: z.number().default(10)
|
|
193
|
+
}).prefault({})
|
|
194
|
+
})]).prefault({ enable: true }),
|
|
195
|
+
enableUaAndHeaderChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
196
|
+
enable: z.literal(true),
|
|
197
|
+
penalties: z.object({
|
|
198
|
+
headlessBrowser: z.number().default(100),
|
|
199
|
+
shortUserAgent: z.number().default(80),
|
|
200
|
+
tlsCheckFailed: z.number().default(60),
|
|
201
|
+
badUaChecker: z.boolean().default(true)
|
|
202
|
+
}).prefault({})
|
|
203
|
+
})]).prefault({ enable: true }),
|
|
204
|
+
enableBrowserAndDeviceChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
205
|
+
enable: z.literal(true),
|
|
206
|
+
penalties: z.object({
|
|
207
|
+
cliOrLibrary: z.number().default(100),
|
|
208
|
+
internetExplorer: z.number().default(100),
|
|
209
|
+
linuxOs: z.number().default(10),
|
|
210
|
+
impossibleBrowserCombinations: z.number().default(30),
|
|
211
|
+
browserTypeUnknown: z.number().default(10),
|
|
212
|
+
browserNameUnknown: z.number().default(10),
|
|
213
|
+
desktopWithoutOS: z.number().default(10),
|
|
214
|
+
deviceVendorUnknown: z.number().default(10),
|
|
215
|
+
browserVersionUnknown: z.number().default(10),
|
|
216
|
+
deviceModelUnknown: z.number().default(5)
|
|
217
|
+
}).prefault({})
|
|
218
|
+
})]).prefault({ enable: true }),
|
|
219
|
+
enableGeoChecks: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
220
|
+
enable: z.literal(true),
|
|
221
|
+
bannedCountries: z.array(z.string()).optional().default([]),
|
|
222
|
+
penalties: z.object({
|
|
223
|
+
countryUnknown: z.number().default(10),
|
|
224
|
+
regionUnknown: z.number().default(10),
|
|
225
|
+
latLonUnknown: z.number().default(10),
|
|
226
|
+
districtUnknown: z.number().default(10),
|
|
227
|
+
cityUnknown: z.number().default(10),
|
|
228
|
+
timezoneUnknown: z.number().default(10),
|
|
229
|
+
subregionUnknown: z.number().default(10),
|
|
230
|
+
phoneUnknown: z.number().default(10),
|
|
231
|
+
continentUnknown: z.number().default(10)
|
|
232
|
+
}).prefault({})
|
|
233
|
+
})]).prefault({ enable: true }),
|
|
234
|
+
enableKnownThreatsDetections: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
235
|
+
enable: z.literal(true),
|
|
236
|
+
penalties: z.object({
|
|
237
|
+
anonymiseNetwork: z.number().default(20),
|
|
238
|
+
threatLevels: z.object({
|
|
239
|
+
criticalLevel1: z.number().default(40),
|
|
240
|
+
currentAttacksLevel2: z.number().default(30),
|
|
241
|
+
threatLevel3: z.number().default(20),
|
|
242
|
+
threatLevel4: z.number().default(10)
|
|
243
|
+
}).prefault({})
|
|
244
|
+
}).prefault({})
|
|
245
|
+
})]).prefault({ enable: true }),
|
|
246
|
+
enableAsnClassification: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
247
|
+
enable: z.literal(true),
|
|
248
|
+
penalties: z.object({
|
|
249
|
+
contentClassification: z.number().default(20),
|
|
250
|
+
unknownClassification: z.number().default(10),
|
|
251
|
+
lowVisibilityPenalty: z.number().default(10),
|
|
252
|
+
lowVisibilityThreshold: z.number().default(15),
|
|
253
|
+
comboHostingLowVisibility: z.number().default(20)
|
|
254
|
+
}).prefault({})
|
|
255
|
+
})]).prefault({ enable: true }),
|
|
256
|
+
enableTorAnalysis: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
257
|
+
enable: z.literal(true),
|
|
258
|
+
penalties: z.object({
|
|
259
|
+
runningNode: z.number().default(15),
|
|
260
|
+
exitNode: z.number().default(20),
|
|
261
|
+
webExitCapable: z.number().default(15),
|
|
262
|
+
guardNode: z.number().default(10),
|
|
263
|
+
badExit: z.number().default(40),
|
|
264
|
+
obsoleteVersion: z.number().default(10)
|
|
265
|
+
}).prefault({})
|
|
266
|
+
})]).prefault({ enable: true }),
|
|
267
|
+
enableTimezoneConsistency: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
268
|
+
enable: z.literal(true),
|
|
269
|
+
penalties: z.number().default(20)
|
|
270
|
+
})]).prefault({ enable: true }),
|
|
271
|
+
honeypot: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
272
|
+
enable: z.literal(true),
|
|
273
|
+
paths: z.array(z.string()).default([])
|
|
274
|
+
})]).prefault({ enable: true }),
|
|
275
|
+
enableSessionCoherence: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
276
|
+
enable: z.literal(true),
|
|
277
|
+
penalties: z.object({
|
|
278
|
+
pathMismatch: z.number().default(10),
|
|
279
|
+
missingReferer: z.number().default(20),
|
|
280
|
+
domainMismatch: z.number().default(30)
|
|
281
|
+
}).prefault({})
|
|
282
|
+
})]).prefault({ enable: true }),
|
|
283
|
+
enableVelocityFingerprint: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
284
|
+
enable: z.literal(true),
|
|
285
|
+
cvThreshold: z.number().default(.1),
|
|
286
|
+
penalties: z.number().default(40)
|
|
287
|
+
})]).prefault({ enable: true }),
|
|
288
|
+
enableKnownBadIpsCheck: z.discriminatedUnion("enable", [z.object({ enable: z.literal(false) }), z.object({
|
|
289
|
+
enable: z.literal(true),
|
|
290
|
+
highRiskPenalty: z.number().default(30)
|
|
291
|
+
})]).prefault({ enable: true })
|
|
292
|
+
}),
|
|
293
|
+
generator: z.object({
|
|
294
|
+
scoreThreshold: z.number().default(70),
|
|
295
|
+
generateTypes: z.boolean().default(false),
|
|
296
|
+
deleteAfterBuild: z.boolean().default(false),
|
|
297
|
+
mmdbctlPath: z.string().default("mmdbctl")
|
|
298
|
+
}).prefault({}),
|
|
299
|
+
headerOptions: z.object({
|
|
300
|
+
weightPerMustHeader: z.number().default(20),
|
|
301
|
+
missingBrowserEngine: z.number().default(30),
|
|
302
|
+
postManOrInsomiaHeaders: z.number().default(50),
|
|
303
|
+
AJAXHeaderExists: z.number().default(30),
|
|
304
|
+
connectionHeaderIsClose: z.number().default(20),
|
|
305
|
+
originHeaderIsNULL: z.number().default(10),
|
|
306
|
+
originHeaderMismatch: z.number().default(30),
|
|
307
|
+
omittedAcceptHeader: z.number().default(30),
|
|
308
|
+
clientHintsMissingForBlink: z.number().default(30),
|
|
309
|
+
teHeaderUnexpectedForBlink: z.number().default(10),
|
|
310
|
+
clientHintsUnexpectedForGecko: z.number().default(30),
|
|
311
|
+
teHeaderMissingForGecko: z.number().default(20),
|
|
312
|
+
aggressiveCacheControlOnGet: z.number().default(15),
|
|
313
|
+
crossSiteRequestMissingReferer: z.number().default(10),
|
|
314
|
+
inconsistentSecFetchMode: z.number().default(20),
|
|
315
|
+
hostMismatchWeight: z.number().default(40)
|
|
316
|
+
}).prefault({}),
|
|
317
|
+
pathTraveler: z.object({
|
|
318
|
+
maxIterations: z.number().default(3),
|
|
319
|
+
maxPathLength: z.number().default(1500),
|
|
320
|
+
pathLengthToLong: z.number().default(100),
|
|
321
|
+
longDecoding: z.number().default(100),
|
|
322
|
+
traversalDetected: z.number().default(60)
|
|
323
|
+
}).prefault({}),
|
|
324
|
+
punishmentType: z.object({ enableFireWallBan: z.boolean().default(false) }).prefault({}),
|
|
325
|
+
logLevel: z.enum([
|
|
326
|
+
"debug",
|
|
327
|
+
"info",
|
|
328
|
+
"warn",
|
|
329
|
+
"error",
|
|
330
|
+
"fatal"
|
|
331
|
+
]).default("info")
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/botDetector/utils/logger.ts
|
|
336
|
+
const LOG_DIR = path$1.resolve(process.cwd(), process.env.LOG_DIR || "bot-detector-logs");
|
|
337
|
+
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
|
338
|
+
const transport = pino.transport({ targets: [
|
|
339
|
+
{
|
|
340
|
+
target: "pino/file",
|
|
341
|
+
level: "info",
|
|
342
|
+
options: {
|
|
343
|
+
destination: `${LOG_DIR}/info.log`,
|
|
344
|
+
mkdir: true
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
target: "pino/file",
|
|
349
|
+
level: "warn",
|
|
350
|
+
options: {
|
|
351
|
+
destination: `${LOG_DIR}/warn.log`,
|
|
352
|
+
mkdir: true
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
target: "pino/file",
|
|
357
|
+
level: "error",
|
|
358
|
+
options: {
|
|
359
|
+
destination: `${LOG_DIR}/errors.log`,
|
|
360
|
+
mkdir: true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
] });
|
|
364
|
+
let logger;
|
|
365
|
+
function getLogger() {
|
|
366
|
+
if (logger) return logger;
|
|
367
|
+
const { logLevel } = getConfiguration();
|
|
368
|
+
logger = pino({
|
|
369
|
+
level: logLevel,
|
|
370
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
371
|
+
mixin() {
|
|
372
|
+
return { uptime: process.uptime() };
|
|
373
|
+
},
|
|
374
|
+
redact: {
|
|
375
|
+
paths: [
|
|
376
|
+
"*.password",
|
|
377
|
+
"*.email",
|
|
378
|
+
"name",
|
|
379
|
+
"Name",
|
|
380
|
+
"*.cookies",
|
|
381
|
+
"*.cookie",
|
|
382
|
+
"cookies",
|
|
383
|
+
"cookie",
|
|
384
|
+
"*.accessToken",
|
|
385
|
+
"*.refresh_token",
|
|
386
|
+
"*.secret"
|
|
387
|
+
],
|
|
388
|
+
censor: "[SECRET]"
|
|
389
|
+
}
|
|
390
|
+
}, transport);
|
|
391
|
+
return logger;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/botDetector/db/dialectUtils.ts
|
|
396
|
+
function isMySQL(db) {
|
|
397
|
+
return db.dialect === "mysql";
|
|
398
|
+
}
|
|
399
|
+
function isSQLite(db) {
|
|
400
|
+
return db.dialect === "sqlite";
|
|
401
|
+
}
|
|
402
|
+
/** Convert ? placeholders to $1, $2, for PostgreSQL. */
|
|
403
|
+
function prep(db, sql) {
|
|
404
|
+
if (db.dialect !== "postgresql") return db.prepare(sql);
|
|
405
|
+
let i = 0;
|
|
406
|
+
return db.prepare(sql.replace(/\?/g, () => `$${String(++i)}`));
|
|
407
|
+
}
|
|
408
|
+
/** Generate positional placeholders for a dynamic list of values. */
|
|
409
|
+
function placeholders(db, count, offset = 0) {
|
|
410
|
+
return Array.from({ length: count }, (_, i) => db.dialect === "postgresql" ? `$${String(offset + i + 1)}` : "?").join(", ");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/botDetector/checkers/CheckerRegistry.ts
|
|
415
|
+
let registeredCheckers = [];
|
|
416
|
+
const CheckerRegistry = {
|
|
417
|
+
register(checker) {
|
|
418
|
+
consola.log(`Loaded plugin: ${checker.name}`);
|
|
419
|
+
registeredCheckers.push(checker);
|
|
420
|
+
},
|
|
421
|
+
getEnabled(phase, config) {
|
|
422
|
+
return registeredCheckers.filter((checker) => checker.phase === phase && checker.isEnabled(config));
|
|
423
|
+
},
|
|
424
|
+
clear() {
|
|
425
|
+
registeredCheckers = [];
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/botDetector/checkers/badUaChecker.ts
|
|
431
|
+
const SEVERITY_ORDER = [
|
|
432
|
+
"critical",
|
|
433
|
+
"high",
|
|
434
|
+
"medium",
|
|
435
|
+
"low"
|
|
436
|
+
];
|
|
437
|
+
let patterns = [];
|
|
438
|
+
function loadUaPatterns() {
|
|
439
|
+
const db = getDataSources().getUserAgentLmdb();
|
|
440
|
+
const bucketStrings = new Map(SEVERITY_ORDER.map((sev) => [sev, []]));
|
|
441
|
+
for (const { value } of db.getRange({ limit: 1e4 })) {
|
|
442
|
+
const sev = value.metadata_severity;
|
|
443
|
+
bucketStrings.get(sev)?.push(value.useragent_rx);
|
|
444
|
+
}
|
|
445
|
+
patterns = SEVERITY_ORDER.map((severity) => {
|
|
446
|
+
const patterns = bucketStrings.get(severity) ?? [];
|
|
447
|
+
if (patterns.length === 0) return null;
|
|
448
|
+
const combined = patterns.map((p) => `(?:${p})`).join("|");
|
|
449
|
+
return {
|
|
450
|
+
rx: new RegExp(combined, "i"),
|
|
451
|
+
severity
|
|
452
|
+
};
|
|
453
|
+
}).filter((p) => p !== null);
|
|
454
|
+
}
|
|
455
|
+
var BadUaChecker = class {
|
|
456
|
+
constructor() {
|
|
457
|
+
this.name = "Bad User Agent list";
|
|
458
|
+
this.phase = "heavy";
|
|
459
|
+
}
|
|
460
|
+
isEnabled(config) {
|
|
461
|
+
return config.checkers.knownBadUserAgents.enable;
|
|
462
|
+
}
|
|
463
|
+
run(ctx, config) {
|
|
464
|
+
const { knownBadUserAgents } = config.checkers;
|
|
465
|
+
const reasons = [];
|
|
466
|
+
let score = 0;
|
|
467
|
+
if (!knownBadUserAgents.enable) return {
|
|
468
|
+
score,
|
|
469
|
+
reasons
|
|
470
|
+
};
|
|
471
|
+
if (patterns.length === 0) loadUaPatterns();
|
|
472
|
+
const rawUa = ctx.req.get("User-Agent") ?? "";
|
|
473
|
+
for (const { rx, severity } of patterns) if (rx.test(rawUa)) {
|
|
474
|
+
reasons.push("BAD_UA_DETECTED");
|
|
475
|
+
switch (severity) {
|
|
476
|
+
case "critical":
|
|
477
|
+
score += knownBadUserAgents.penalties.criticalSeverity;
|
|
478
|
+
break;
|
|
479
|
+
case "high":
|
|
480
|
+
score += knownBadUserAgents.penalties.highSeverity;
|
|
481
|
+
break;
|
|
482
|
+
case "medium":
|
|
483
|
+
score += knownBadUserAgents.penalties.mediumSeverity;
|
|
484
|
+
break;
|
|
485
|
+
case "low":
|
|
486
|
+
score += knownBadUserAgents.penalties.lowSeverity;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
score,
|
|
493
|
+
reasons
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
CheckerRegistry.register(new BadUaChecker());
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/botDetector/config/config.ts
|
|
501
|
+
const { defineConfiguration, getConfiguration } = createConfigManager(configSchema, "Bot Detector");
|
|
502
|
+
let globalDataSources;
|
|
503
|
+
let globalDb;
|
|
504
|
+
function getDb() {
|
|
505
|
+
if (!globalDb) {
|
|
506
|
+
consola.trace("Premature getDb() call");
|
|
507
|
+
throw new Error("DB not ready. Call configuration() first.");
|
|
508
|
+
}
|
|
509
|
+
return globalDb;
|
|
510
|
+
}
|
|
511
|
+
function getDataSources() {
|
|
512
|
+
if (!globalDataSources) {
|
|
513
|
+
consola.trace("Premature getDataSources() call");
|
|
514
|
+
throw new Error(`##### Must be initialized globally #####
|
|
515
|
+
Bot Detector: DataSources not ready. Call configuration() first.`);
|
|
516
|
+
}
|
|
517
|
+
return globalDataSources;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/botDetector/db/generator.ts
|
|
522
|
+
const MMDB_DIR = path.resolve(getLibraryRoot(), "_data-sources");
|
|
523
|
+
async function buildBannedMmdb(generateTypes, mmdbctlPath) {
|
|
524
|
+
const log = getLogger().child({
|
|
525
|
+
service: "BOT DETECTOR",
|
|
526
|
+
branch: "generator",
|
|
527
|
+
db: "banned"
|
|
528
|
+
});
|
|
529
|
+
const db = getDb();
|
|
530
|
+
const rows = await db.prepare(`SELECT ip_address, country, user_agent, reason, score
|
|
531
|
+
FROM banned
|
|
532
|
+
WHERE ip_address IS NOT NULL`).all();
|
|
533
|
+
if (rows.length === 0) {
|
|
534
|
+
log.info("No banned IPs — skipping banned.mmdb");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const data = rows.map((r) => ({
|
|
538
|
+
range: r.ip_address,
|
|
539
|
+
score: r.score,
|
|
540
|
+
country: r.country,
|
|
541
|
+
userAgent: r.user_agent,
|
|
542
|
+
reason: r.reason,
|
|
543
|
+
comment: "Automatically generated by @riavzon/botDetector"
|
|
544
|
+
}));
|
|
545
|
+
await compiler({
|
|
546
|
+
type: "mmdb",
|
|
547
|
+
input: {
|
|
548
|
+
data,
|
|
549
|
+
dataBaseName: "banned",
|
|
550
|
+
outputPath: MMDB_DIR,
|
|
551
|
+
mmdbPath: mmdbctlPath,
|
|
552
|
+
generateTypes
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
log.info(`banned.mmdb compiled — ${String(data.length)} entries`);
|
|
556
|
+
if (getConfiguration().generator.deleteAfterBuild) {
|
|
557
|
+
const compiled = rows.map((r) => r.ip_address);
|
|
558
|
+
await prep(db, `DELETE FROM banned WHERE ip_address IN (${placeholders(db, compiled.length)})`).run(...compiled);
|
|
559
|
+
log.info(`Deleted ${String(compiled.length)} rows from banned after build`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async function buildHighRiskMmdb(scoreThreshold, generateTypes, mmdbctlPath) {
|
|
563
|
+
const log = getLogger().child({
|
|
564
|
+
service: "BOT DETECTOR",
|
|
565
|
+
branch: "generator",
|
|
566
|
+
db: "highRisk"
|
|
567
|
+
});
|
|
568
|
+
const db = getDb();
|
|
569
|
+
const rows = await prep(db, `SELECT
|
|
570
|
+
ip_address, visitor_id, suspicious_activity_score,
|
|
571
|
+
country, region, region_name, city, lat, lon, timezone,
|
|
572
|
+
isp, org, browser, browserType, browserVersion, os,
|
|
573
|
+
device_type, deviceVendor, deviceModel,
|
|
574
|
+
proxy, hosting, request_count, first_seen, last_seen
|
|
575
|
+
FROM visitors
|
|
576
|
+
WHERE suspicious_activity_score >= ? AND ip_address IS NOT NULL`).all(scoreThreshold);
|
|
577
|
+
if (rows.length === 0) {
|
|
578
|
+
log.info(`No high-risk visitors (score >= ${String(scoreThreshold)}) — skipping highRisk.mmdb`);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const data = rows.map((r) => ({
|
|
582
|
+
range: r.ip_address,
|
|
583
|
+
visitorId: r.visitor_id,
|
|
584
|
+
score: r.suspicious_activity_score,
|
|
585
|
+
country: r.country,
|
|
586
|
+
region: r.region,
|
|
587
|
+
regionName: r.region_name,
|
|
588
|
+
city: r.city,
|
|
589
|
+
lat: r.lat,
|
|
590
|
+
lon: r.lon,
|
|
591
|
+
timezone: r.timezone,
|
|
592
|
+
isp: r.isp,
|
|
593
|
+
org: r.org,
|
|
594
|
+
browser: r.browser,
|
|
595
|
+
browserType: r.browserType,
|
|
596
|
+
browserVersion: r.browserVersion,
|
|
597
|
+
os: r.os,
|
|
598
|
+
deviceType: r.device_type,
|
|
599
|
+
deviceVendor: r.deviceVendor,
|
|
600
|
+
deviceModel: r.deviceModel,
|
|
601
|
+
proxy: Boolean(r.proxy),
|
|
602
|
+
hosting: Boolean(r.hosting),
|
|
603
|
+
requestCount: r.request_count,
|
|
604
|
+
firstSeen: r.first_seen ? r.first_seen.toISOString() : null,
|
|
605
|
+
lastSeen: r.last_seen ? r.last_seen.toISOString() : null,
|
|
606
|
+
comment: "Automatically generated by @riavzon/botDetector"
|
|
607
|
+
}));
|
|
608
|
+
await compiler({
|
|
609
|
+
type: "mmdb",
|
|
610
|
+
input: {
|
|
611
|
+
data,
|
|
612
|
+
dataBaseName: "highRisk",
|
|
613
|
+
outputPath: MMDB_DIR,
|
|
614
|
+
mmdbPath: mmdbctlPath,
|
|
615
|
+
generateTypes
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
log.info(`highRisk.mmdb compiled — ${String(data.length)} entries`);
|
|
619
|
+
if (getConfiguration().generator.deleteAfterBuild) {
|
|
620
|
+
const compiled = rows.map((r) => r.ip_address);
|
|
621
|
+
await prep(db, `DELETE FROM visitors WHERE ip_address IN (${placeholders(db, compiled.length)}) AND suspicious_activity_score >= ${placeholders(db, 1, compiled.length)}`).run(...compiled, scoreThreshold);
|
|
622
|
+
log.info(`Deleted ${String(compiled.length)} rows from visitors after build`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function runCycle() {
|
|
626
|
+
const log = getLogger().child({
|
|
627
|
+
service: "BOT DETECTOR",
|
|
628
|
+
branch: "generator"
|
|
629
|
+
});
|
|
630
|
+
const { generator } = getConfiguration();
|
|
631
|
+
log.info("MMDB generation cycle started");
|
|
632
|
+
try {
|
|
633
|
+
await Promise.all([buildBannedMmdb(generator.generateTypes, generator.mmdbctlPath), buildHighRiskMmdb(generator.scoreThreshold, generator.generateTypes, generator.mmdbctlPath)]);
|
|
634
|
+
log.info("MMDB generation cycle complete");
|
|
635
|
+
} catch (err) {
|
|
636
|
+
log.error({ err }, "MMDB generation cycle failed");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async function runGeneration() {
|
|
640
|
+
return runCycle();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/botDetector/cli/commands/cleanUp.ts
|
|
645
|
+
const cleanUp = defineCommand({
|
|
646
|
+
meta: {
|
|
647
|
+
name: "generate",
|
|
648
|
+
description: "Run the generator: reads banned and high-risk visitors from your database and compiles them into an optimized binary (based on your generator configuration)"
|
|
649
|
+
},
|
|
650
|
+
async run() {
|
|
651
|
+
const { generator } = getConfiguration();
|
|
652
|
+
try {
|
|
653
|
+
execSync(`${generator.mmdbctlPath} --help`, { stdio: "ignore" });
|
|
654
|
+
} catch {
|
|
655
|
+
consola.warn(`The configurable mmdbctl path cannot be resolved at ${generator.mmdbctlPath}, You will be prompted to install it.`);
|
|
656
|
+
const path = await __ensureMmdbctl();
|
|
657
|
+
consola.box(`mmdbctl installed successfully. Add the next path in generator.mmdbctlPath configuration option and run the command again: ${path}`);
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
consola.start("Starting clean up operation...");
|
|
661
|
+
await runGeneration();
|
|
662
|
+
consola.success("Success!");
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/botDetector/db/schema.ts
|
|
668
|
+
function visitorIdDefault(db) {
|
|
669
|
+
if (isMySQL(db)) return "NOT NULL DEFAULT (UUID())";
|
|
670
|
+
if (db.dialect === "postgresql") return "NOT NULL DEFAULT gen_random_uuid()";
|
|
671
|
+
return "NOT NULL";
|
|
672
|
+
}
|
|
673
|
+
function lastSeenDef(db) {
|
|
674
|
+
if (isMySQL(db)) return "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP";
|
|
675
|
+
if (isSQLite(db)) return "TEXT DEFAULT CURRENT_TIMESTAMP";
|
|
676
|
+
return "TIMESTAMP DEFAULT CURRENT_TIMESTAMP";
|
|
677
|
+
}
|
|
678
|
+
function tableOptions(db) {
|
|
679
|
+
return isMySQL(db) ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" : "";
|
|
680
|
+
}
|
|
681
|
+
function timestampType(db) {
|
|
682
|
+
return isSQLite(db) ? "TEXT" : "TIMESTAMP";
|
|
683
|
+
}
|
|
684
|
+
async function createTables(db) {
|
|
685
|
+
const visitorId = visitorIdDefault(db);
|
|
686
|
+
const lastSeen = lastSeenDef(db);
|
|
687
|
+
const tblOpts = tableOptions(db);
|
|
688
|
+
const createVisitorsTable = `
|
|
689
|
+
CREATE TABLE IF NOT EXISTS visitors (
|
|
690
|
+
visitor_id CHAR(36) ${visitorId},
|
|
691
|
+
canary_id VARCHAR(64) PRIMARY KEY,
|
|
692
|
+
ip_address VARCHAR(45),
|
|
693
|
+
user_agent TEXT,
|
|
694
|
+
country VARCHAR(64),
|
|
695
|
+
region VARCHAR(64),
|
|
696
|
+
region_name VARCHAR(350),
|
|
697
|
+
city VARCHAR(64),
|
|
698
|
+
district VARCHAR(260),
|
|
699
|
+
lat VARCHAR(150),
|
|
700
|
+
lon VARCHAR(150),
|
|
701
|
+
timezone VARCHAR(64),
|
|
702
|
+
currency VARCHAR(64),
|
|
703
|
+
isp VARCHAR(64),
|
|
704
|
+
org VARCHAR(64),
|
|
705
|
+
as_org VARCHAR(64),
|
|
706
|
+
device_type VARCHAR(64),
|
|
707
|
+
browser VARCHAR(64),
|
|
708
|
+
proxy BOOLEAN,
|
|
709
|
+
hosting BOOLEAN,
|
|
710
|
+
is_bot BOOLEAN DEFAULT false,
|
|
711
|
+
first_seen ${timestampType(db)} DEFAULT CURRENT_TIMESTAMP,
|
|
712
|
+
last_seen ${lastSeen},
|
|
713
|
+
request_count INT DEFAULT 1,
|
|
714
|
+
deviceVendor VARCHAR(64) DEFAULT 'unknown',
|
|
715
|
+
deviceModel VARCHAR(64) DEFAULT 'unknown',
|
|
716
|
+
browserType VARCHAR(64) DEFAULT 'unknown',
|
|
717
|
+
browserVersion VARCHAR(64) DEFAULT 'unknown',
|
|
718
|
+
os VARCHAR(64) DEFAULT 'unknown',
|
|
719
|
+
suspicious_activity_score INT DEFAULT 0
|
|
720
|
+
) ${tblOpts}
|
|
721
|
+
`;
|
|
722
|
+
const createBannedTable = `
|
|
723
|
+
CREATE TABLE IF NOT EXISTS banned (
|
|
724
|
+
canary_id VARCHAR(64) PRIMARY KEY,
|
|
725
|
+
ip_address VARCHAR(45),
|
|
726
|
+
country VARCHAR(64),
|
|
727
|
+
user_agent TEXT,
|
|
728
|
+
reason TEXT,
|
|
729
|
+
score INT DEFAULT NULL,
|
|
730
|
+
FOREIGN KEY (canary_id) REFERENCES visitors(canary_id)
|
|
731
|
+
)
|
|
732
|
+
`;
|
|
733
|
+
try {
|
|
734
|
+
await db.exec(createVisitorsTable);
|
|
735
|
+
await db.exec(createBannedTable);
|
|
736
|
+
consola.success("Tables created successfully.");
|
|
737
|
+
} catch (error) {
|
|
738
|
+
consola.error("Error creating tables:", error);
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/botDetector/cli/commands/makeTables.ts
|
|
745
|
+
const makeTables = defineCommand({
|
|
746
|
+
meta: {
|
|
747
|
+
name: "load-schema",
|
|
748
|
+
description: "Create database tables"
|
|
749
|
+
},
|
|
750
|
+
async run() {
|
|
751
|
+
const db = getDb();
|
|
752
|
+
consola.start(`Creating tables for ${db.dialect}...`);
|
|
753
|
+
await createTables(db);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/botDetector/cli/index.ts
|
|
759
|
+
const main = defineCommand({
|
|
760
|
+
meta: {
|
|
761
|
+
name: "bot-detector",
|
|
762
|
+
description: "Automatic traffic analysis and detection",
|
|
763
|
+
version: "1.0.0"
|
|
764
|
+
},
|
|
765
|
+
subCommands: {
|
|
766
|
+
init: startCommand,
|
|
767
|
+
refresh: refreshData,
|
|
768
|
+
generate: cleanUp,
|
|
769
|
+
"load-schema": makeTables
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
await runMain(main);
|
|
773
|
+
|
|
774
|
+
//#endregion
|
|
775
|
+
export { main };
|
|
776
|
+
//# sourceMappingURL=index.mjs.map
|