@anomira/node-sdk 0.2.3 → 0.2.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.
- package/README.md +42 -11
- package/dist/index.cjs +301 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -3
- package/dist/index.d.ts +31 -3
- package/dist/index.js +301 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -148,11 +148,17 @@ declare const EventName: {
|
|
|
148
148
|
readonly SQL_ERROR: "db.sql.error";
|
|
149
149
|
readonly FIREWALL_BLOCK: "http.firewall.block";
|
|
150
150
|
readonly FIREWALL_FLAG: "http.firewall.flag";
|
|
151
|
+
readonly FIREWALL_RATE_LIMIT: "http.firewall.rate_limit";
|
|
152
|
+
readonly FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot";
|
|
153
|
+
readonly FIREWALL_TAG: "http.firewall.tag";
|
|
154
|
+
readonly FIREWALL_MONITOR: "http.firewall.monitor";
|
|
155
|
+
readonly FIREWALL_CHALLENGE: "http.firewall.challenge";
|
|
156
|
+
readonly FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block";
|
|
151
157
|
};
|
|
152
158
|
type EventNameValue = typeof EventName[keyof typeof EventName];
|
|
153
159
|
type FirewallField = "url" | "body" | "header" | "user_agent" | "ip";
|
|
154
160
|
type FirewallOperator = "contains" | "equals" | "starts_with" | "ends_with" | "regex";
|
|
155
|
-
type FirewallAction = "block" | "flag";
|
|
161
|
+
type FirewallAction = "block" | "flag" | "rate_limit" | "challenge" | "redirect_honeypot" | "tag" | "monitor" | "temporary_block";
|
|
156
162
|
interface EndpointDeclaration {
|
|
157
163
|
/** HTTP method, e.g. "GET". Use "*" to match any method. */
|
|
158
164
|
method: string;
|
|
@@ -169,6 +175,8 @@ interface FirewallRule {
|
|
|
169
175
|
value: string;
|
|
170
176
|
action: FirewallAction;
|
|
171
177
|
attackType: string;
|
|
178
|
+
redirectTarget?: string | null;
|
|
179
|
+
tempBlockMins?: number | null;
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/**
|
|
@@ -228,13 +236,33 @@ declare class AnomiraClient {
|
|
|
228
236
|
private readonly _origWarn;
|
|
229
237
|
private readonly _origError;
|
|
230
238
|
constructor(config: AnomiraConfig);
|
|
239
|
+
/**
|
|
240
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
241
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
242
|
+
*
|
|
243
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
244
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
245
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
246
|
+
*
|
|
247
|
+
* ```ts
|
|
248
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
249
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
250
|
+
* ip: sentinel.getClientIp(req),
|
|
251
|
+
* userId: req.body.phone,
|
|
252
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
253
|
+
* });
|
|
254
|
+
*
|
|
255
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
256
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
getClientIp(req: unknown): string;
|
|
231
260
|
/**
|
|
232
261
|
* Track a custom security event.
|
|
233
262
|
*
|
|
234
263
|
* ```ts
|
|
235
|
-
* // Track a failed OTP attempt
|
|
236
264
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
237
|
-
* ip: req.ip
|
|
265
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
238
266
|
* userId: req.body.phone,
|
|
239
267
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
240
268
|
* });
|
package/dist/index.d.ts
CHANGED
|
@@ -148,11 +148,17 @@ declare const EventName: {
|
|
|
148
148
|
readonly SQL_ERROR: "db.sql.error";
|
|
149
149
|
readonly FIREWALL_BLOCK: "http.firewall.block";
|
|
150
150
|
readonly FIREWALL_FLAG: "http.firewall.flag";
|
|
151
|
+
readonly FIREWALL_RATE_LIMIT: "http.firewall.rate_limit";
|
|
152
|
+
readonly FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot";
|
|
153
|
+
readonly FIREWALL_TAG: "http.firewall.tag";
|
|
154
|
+
readonly FIREWALL_MONITOR: "http.firewall.monitor";
|
|
155
|
+
readonly FIREWALL_CHALLENGE: "http.firewall.challenge";
|
|
156
|
+
readonly FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block";
|
|
151
157
|
};
|
|
152
158
|
type EventNameValue = typeof EventName[keyof typeof EventName];
|
|
153
159
|
type FirewallField = "url" | "body" | "header" | "user_agent" | "ip";
|
|
154
160
|
type FirewallOperator = "contains" | "equals" | "starts_with" | "ends_with" | "regex";
|
|
155
|
-
type FirewallAction = "block" | "flag";
|
|
161
|
+
type FirewallAction = "block" | "flag" | "rate_limit" | "challenge" | "redirect_honeypot" | "tag" | "monitor" | "temporary_block";
|
|
156
162
|
interface EndpointDeclaration {
|
|
157
163
|
/** HTTP method, e.g. "GET". Use "*" to match any method. */
|
|
158
164
|
method: string;
|
|
@@ -169,6 +175,8 @@ interface FirewallRule {
|
|
|
169
175
|
value: string;
|
|
170
176
|
action: FirewallAction;
|
|
171
177
|
attackType: string;
|
|
178
|
+
redirectTarget?: string | null;
|
|
179
|
+
tempBlockMins?: number | null;
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/**
|
|
@@ -228,13 +236,33 @@ declare class AnomiraClient {
|
|
|
228
236
|
private readonly _origWarn;
|
|
229
237
|
private readonly _origError;
|
|
230
238
|
constructor(config: AnomiraConfig);
|
|
239
|
+
/**
|
|
240
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
241
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
242
|
+
*
|
|
243
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
244
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
245
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
246
|
+
*
|
|
247
|
+
* ```ts
|
|
248
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
249
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
250
|
+
* ip: sentinel.getClientIp(req),
|
|
251
|
+
* userId: req.body.phone,
|
|
252
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
253
|
+
* });
|
|
254
|
+
*
|
|
255
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
256
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
getClientIp(req: unknown): string;
|
|
231
260
|
/**
|
|
232
261
|
* Track a custom security event.
|
|
233
262
|
*
|
|
234
263
|
* ```ts
|
|
235
|
-
* // Track a failed OTP attempt
|
|
236
264
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
237
|
-
* ip: req.ip
|
|
265
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
238
266
|
* userId: req.body.phone,
|
|
239
267
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
240
268
|
* });
|
package/dist/index.js
CHANGED
|
@@ -400,7 +400,13 @@ var EventName = {
|
|
|
400
400
|
SQL_ERROR: "db.sql.error",
|
|
401
401
|
// Firewall (emitted when a custom request filtering rule fires)
|
|
402
402
|
FIREWALL_BLOCK: "http.firewall.block",
|
|
403
|
-
FIREWALL_FLAG: "http.firewall.flag"
|
|
403
|
+
FIREWALL_FLAG: "http.firewall.flag",
|
|
404
|
+
FIREWALL_RATE_LIMIT: "http.firewall.rate_limit",
|
|
405
|
+
FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot",
|
|
406
|
+
FIREWALL_TAG: "http.firewall.tag",
|
|
407
|
+
FIREWALL_MONITOR: "http.firewall.monitor",
|
|
408
|
+
FIREWALL_CHALLENGE: "http.firewall.challenge",
|
|
409
|
+
FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block"
|
|
404
410
|
};
|
|
405
411
|
|
|
406
412
|
// src/agent-detection.ts
|
|
@@ -517,6 +523,9 @@ var KNOWN_BOT_UA = [
|
|
|
517
523
|
[/^Ruby$/i, "Ruby"]
|
|
518
524
|
];
|
|
519
525
|
var AXIOS_ACCEPT = "application/json, text/plain, */*";
|
|
526
|
+
function isBrowserUa(ua) {
|
|
527
|
+
return /Mozilla\/5\.0/.test(ua) && /(?:Chrome|Firefox|Safari|OPR|Edg(?:e|HTML)?|Trident)\/[\d.]+/.test(ua);
|
|
528
|
+
}
|
|
520
529
|
function computeBrowserFingerprint(headers, ua) {
|
|
521
530
|
const signals = [];
|
|
522
531
|
let score = 0;
|
|
@@ -533,10 +542,15 @@ function computeBrowserFingerprint(headers, ua) {
|
|
|
533
542
|
}
|
|
534
543
|
const accept = str(headers["accept"]);
|
|
535
544
|
if (!isDefiniteBot && accept === AXIOS_ACCEPT) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
545
|
+
if (!isBrowserUa(ua)) {
|
|
546
|
+
isDefiniteBot = true;
|
|
547
|
+
knownClient = "axios";
|
|
548
|
+
score = 100;
|
|
549
|
+
signals.push("known_accept:axios");
|
|
550
|
+
} else {
|
|
551
|
+
score += 15;
|
|
552
|
+
signals.push("browser_axios_accept");
|
|
553
|
+
}
|
|
540
554
|
}
|
|
541
555
|
if (isDefiniteBot) return { score, signals, isDefiniteBot, knownClient };
|
|
542
556
|
const hasSecFetchSite = "sec-fetch-site" in headers;
|
|
@@ -1143,6 +1157,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
|
|
|
1143
1157
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1144
1158
|
var DEFAULT_FLUSH_MS = 5e3;
|
|
1145
1159
|
var DEFAULT_MAX_RETRIES = 3;
|
|
1160
|
+
var SENSITIVE_FIELDS = {
|
|
1161
|
+
identity: [
|
|
1162
|
+
"first_name",
|
|
1163
|
+
"last_name",
|
|
1164
|
+
"full_name",
|
|
1165
|
+
"name",
|
|
1166
|
+
"bvn",
|
|
1167
|
+
"nin",
|
|
1168
|
+
"ssn",
|
|
1169
|
+
"national_id",
|
|
1170
|
+
"tin",
|
|
1171
|
+
"passport",
|
|
1172
|
+
"passport_number",
|
|
1173
|
+
"drivers_license",
|
|
1174
|
+
"license_number",
|
|
1175
|
+
"voter_id",
|
|
1176
|
+
"dob",
|
|
1177
|
+
"date_of_birth",
|
|
1178
|
+
"birth_date",
|
|
1179
|
+
"birthdate",
|
|
1180
|
+
"gender",
|
|
1181
|
+
"marital_status",
|
|
1182
|
+
"nationality"
|
|
1183
|
+
],
|
|
1184
|
+
contact: [
|
|
1185
|
+
"email",
|
|
1186
|
+
"phone",
|
|
1187
|
+
"phone_number",
|
|
1188
|
+
"mobile",
|
|
1189
|
+
"mobile_number",
|
|
1190
|
+
"address",
|
|
1191
|
+
"street",
|
|
1192
|
+
"city",
|
|
1193
|
+
"state",
|
|
1194
|
+
"postal_code",
|
|
1195
|
+
"zip"
|
|
1196
|
+
],
|
|
1197
|
+
financial: [
|
|
1198
|
+
"card_number",
|
|
1199
|
+
"credit_card",
|
|
1200
|
+
"debit_card",
|
|
1201
|
+
"cvv",
|
|
1202
|
+
"account_number",
|
|
1203
|
+
"bank_account",
|
|
1204
|
+
"routing_number",
|
|
1205
|
+
"sort_code",
|
|
1206
|
+
"iban",
|
|
1207
|
+
"swift",
|
|
1208
|
+
"wallet_id"
|
|
1209
|
+
],
|
|
1210
|
+
authentication: [
|
|
1211
|
+
"password",
|
|
1212
|
+
"pin",
|
|
1213
|
+
"otp",
|
|
1214
|
+
"secret",
|
|
1215
|
+
"private_key",
|
|
1216
|
+
"api_key",
|
|
1217
|
+
"access_token",
|
|
1218
|
+
"refresh_token",
|
|
1219
|
+
"jwt",
|
|
1220
|
+
"bearer_token",
|
|
1221
|
+
"security_answer",
|
|
1222
|
+
"mother_maiden_name"
|
|
1223
|
+
],
|
|
1224
|
+
biometric: [
|
|
1225
|
+
"fingerprint",
|
|
1226
|
+
"face_id",
|
|
1227
|
+
"iris",
|
|
1228
|
+
"voiceprint",
|
|
1229
|
+
"biometric"
|
|
1230
|
+
],
|
|
1231
|
+
health: [
|
|
1232
|
+
"medical_record",
|
|
1233
|
+
"diagnosis",
|
|
1234
|
+
"blood_group",
|
|
1235
|
+
"insurance_id"
|
|
1236
|
+
],
|
|
1237
|
+
fintech: [
|
|
1238
|
+
"kyc_id",
|
|
1239
|
+
"customer_id",
|
|
1240
|
+
"beneficiary_account",
|
|
1241
|
+
"transaction_pin",
|
|
1242
|
+
"wallet_balance",
|
|
1243
|
+
"bank_verification_number",
|
|
1244
|
+
"virtual_account",
|
|
1245
|
+
"monnify_account",
|
|
1246
|
+
"paystack_customer",
|
|
1247
|
+
"flutterwave_customer"
|
|
1248
|
+
]
|
|
1249
|
+
};
|
|
1250
|
+
var FIELD_CATEGORY_MAP = /* @__PURE__ */ new Map();
|
|
1251
|
+
for (const [cat, fields] of Object.entries(SENSITIVE_FIELDS)) {
|
|
1252
|
+
for (const f of fields) FIELD_CATEGORY_MAP.set(f, cat);
|
|
1253
|
+
}
|
|
1254
|
+
var VALUE_PATTERNS = [
|
|
1255
|
+
{ name: "email", category: "contact", re: /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ },
|
|
1256
|
+
{ name: "bvn_nin", category: "identity", re: /^\d{11}$/ },
|
|
1257
|
+
{ name: "card_number", category: "financial", re: /^\d{13,19}$/ },
|
|
1258
|
+
{ name: "phone_ng", category: "contact", re: /^(?:\+234|0)[789][01]\d{8}$/ },
|
|
1259
|
+
{ name: "jwt", category: "authentication", re: /^eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\./ },
|
|
1260
|
+
{ name: "iban", category: "financial", re: /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/ }
|
|
1261
|
+
];
|
|
1146
1262
|
var AnomiraClient = class {
|
|
1147
1263
|
constructor(config) {
|
|
1148
1264
|
this.logBuffer = [];
|
|
@@ -1475,13 +1591,35 @@ var AnomiraClient = class {
|
|
|
1475
1591
|
}
|
|
1476
1592
|
}
|
|
1477
1593
|
// ─── Public API ────────────────────────────────────────────────────────────
|
|
1594
|
+
/**
|
|
1595
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
1596
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
1597
|
+
*
|
|
1598
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
1599
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
1600
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
1601
|
+
*
|
|
1602
|
+
* ```ts
|
|
1603
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
1604
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
1605
|
+
* ip: sentinel.getClientIp(req),
|
|
1606
|
+
* userId: req.body.phone,
|
|
1607
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
1608
|
+
* });
|
|
1609
|
+
*
|
|
1610
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
1611
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
1612
|
+
* ```
|
|
1613
|
+
*/
|
|
1614
|
+
getClientIp(req) {
|
|
1615
|
+
return this.config.getIp(req);
|
|
1616
|
+
}
|
|
1478
1617
|
/**
|
|
1479
1618
|
* Track a custom security event.
|
|
1480
1619
|
*
|
|
1481
1620
|
* ```ts
|
|
1482
|
-
* // Track a failed OTP attempt
|
|
1483
1621
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
1484
|
-
* ip: req.ip
|
|
1622
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
1485
1623
|
* userId: req.body.phone,
|
|
1486
1624
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
1487
1625
|
* });
|
|
@@ -1554,6 +1692,11 @@ var AnomiraClient = class {
|
|
|
1554
1692
|
if (this.disabled) return;
|
|
1555
1693
|
const ctx = requestContext.getStore();
|
|
1556
1694
|
const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
|
|
1695
|
+
if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
|
|
1696
|
+
this._origWarn(
|
|
1697
|
+
`[Anomira] Warning: track() received a private/loopback IP "${data.ip}" for event "${eventName}". Behind a reverse proxy, req.ip is the proxy's address \u2014 use sentinel.getClientIp(req) instead.`
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1557
1700
|
const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
|
|
1558
1701
|
const event = {
|
|
1559
1702
|
name: eventName,
|
|
@@ -1794,6 +1937,9 @@ function normalizeIp(raw) {
|
|
|
1794
1937
|
if (raw.startsWith("::ffff:")) return raw.slice(7);
|
|
1795
1938
|
return raw;
|
|
1796
1939
|
}
|
|
1940
|
+
function isPrivateIp2(ip) {
|
|
1941
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("10.") || ip.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
|
|
1942
|
+
}
|
|
1797
1943
|
function defaultGetIp(req) {
|
|
1798
1944
|
const r = req;
|
|
1799
1945
|
const fwd = r["headers"];
|
|
@@ -1894,6 +2040,27 @@ function createExpressMiddleware(client) {
|
|
|
1894
2040
|
const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
|
|
1895
2041
|
const h2settings = extractHttp2Settings(req);
|
|
1896
2042
|
const upstreamTls = extractUpstreamTls(headers ?? {});
|
|
2043
|
+
const cookieHeader = headers?.["cookie"];
|
|
2044
|
+
const cookieToken = cookieHeader ? cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith("anomira_fp="))?.slice("anomira_fp=".length) ?? "" : "";
|
|
2045
|
+
const browserFpRaw = cookieToken || headers?.["x-anomira-fp"] || "";
|
|
2046
|
+
let browserFp = null;
|
|
2047
|
+
if (browserFpRaw) {
|
|
2048
|
+
try {
|
|
2049
|
+
const raw = JSON.parse(Buffer.from(browserFpRaw, "base64").toString("utf8"));
|
|
2050
|
+
if (typeof raw["v"] === "number" && typeof raw["fp"] === "string") {
|
|
2051
|
+
browserFp = {
|
|
2052
|
+
fp: raw["fp"],
|
|
2053
|
+
bot: raw["bot"] ?? 0,
|
|
2054
|
+
sigs: (raw["sigs"] ?? []).join(","),
|
|
2055
|
+
uid: typeof raw["uid"] === "string" && raw["uid"] ? raw["uid"] : void 0,
|
|
2056
|
+
pst: raw["frm"]?.pst,
|
|
2057
|
+
ttf: raw["frm"]?.ttf,
|
|
2058
|
+
tts: raw["frm"]?.tts
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
} catch {
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
1897
2064
|
if (client["canaryTokenCache"] && client["canaryTokenCache"].size > 0) {
|
|
1898
2065
|
const authHeader = (typeof headers?.["authorization"] === "string" ? headers["authorization"] : "") ?? "";
|
|
1899
2066
|
if (authHeader.startsWith("Bearer ")) {
|
|
@@ -1988,11 +2155,39 @@ function createExpressMiddleware(client) {
|
|
|
1988
2155
|
userId,
|
|
1989
2156
|
meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
|
|
1990
2157
|
});
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
if (typeof res_["status"] === "function") res_["status"](
|
|
1994
|
-
else res_["statusCode"] =
|
|
1995
|
-
|
|
2158
|
+
const res_ = res;
|
|
2159
|
+
const setStatus = (code) => {
|
|
2160
|
+
if (typeof res_["status"] === "function") res_["status"](code);
|
|
2161
|
+
else res_["statusCode"] = code;
|
|
2162
|
+
};
|
|
2163
|
+
const sendBody = (body) => {
|
|
2164
|
+
if (typeof res_["end"] === "function") res_["end"](body);
|
|
2165
|
+
};
|
|
2166
|
+
const setHeader = (k, v) => {
|
|
2167
|
+
if (typeof res_["setHeader"] === "function") res_["setHeader"](k, v);
|
|
2168
|
+
};
|
|
2169
|
+
const action = fwMatch.rule.action;
|
|
2170
|
+
if (action === "block" || action === "temporary_block") {
|
|
2171
|
+
setStatus(403);
|
|
2172
|
+
sendBody('{"error":"Blocked by firewall rule"}');
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (action === "rate_limit") {
|
|
2176
|
+
setStatus(429);
|
|
2177
|
+
setHeader("Retry-After", "60");
|
|
2178
|
+
sendBody('{"error":"Rate limit exceeded","retryAfter":60}');
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (action === "redirect_honeypot") {
|
|
2182
|
+
const target = fwMatch.rule.redirectTarget ?? "/.env";
|
|
2183
|
+
setStatus(302);
|
|
2184
|
+
setHeader("Location", target);
|
|
2185
|
+
sendBody("");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (action === "challenge") {
|
|
2189
|
+
setStatus(403);
|
|
2190
|
+
sendBody('{"error":"Request challenge required"}');
|
|
1996
2191
|
return;
|
|
1997
2192
|
}
|
|
1998
2193
|
}
|
|
@@ -2006,7 +2201,7 @@ function createExpressMiddleware(client) {
|
|
|
2006
2201
|
}
|
|
2007
2202
|
}
|
|
2008
2203
|
const onFinish = () => {
|
|
2009
|
-
const lateUserId = client.config.getUserId(req);
|
|
2204
|
+
const lateUserId = client.config.getUserId(req) || browserFp?.uid || "";
|
|
2010
2205
|
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2011
2206
|
userIdCheckCount++;
|
|
2012
2207
|
if (!lateUserId) userIdMissCount++;
|
|
@@ -2021,6 +2216,30 @@ function createExpressMiddleware(client) {
|
|
|
2021
2216
|
const latencyMs = Date.now() - startMs;
|
|
2022
2217
|
const getHeader = res["getHeader"];
|
|
2023
2218
|
const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
|
|
2219
|
+
const rawBody = req["body"];
|
|
2220
|
+
const piiFields = [];
|
|
2221
|
+
const piiPatterns = [];
|
|
2222
|
+
const piiCatSet = /* @__PURE__ */ new Set();
|
|
2223
|
+
if (rawBody != null && typeof rawBody === "object" && !Array.isArray(rawBody)) {
|
|
2224
|
+
const body = rawBody;
|
|
2225
|
+
for (const key of Object.keys(body)) {
|
|
2226
|
+
const cat = FIELD_CATEGORY_MAP.get(key.toLowerCase());
|
|
2227
|
+
if (cat) {
|
|
2228
|
+
piiFields.push(key.toLowerCase());
|
|
2229
|
+
piiCatSet.add(cat);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
for (const val of Object.values(body)) {
|
|
2233
|
+
if (typeof val !== "string" || val.length > 200) continue;
|
|
2234
|
+
for (const { name, category, re } of VALUE_PATTERNS) {
|
|
2235
|
+
if (!piiPatterns.includes(name) && re.test(val)) {
|
|
2236
|
+
piiPatterns.push(name);
|
|
2237
|
+
piiCatSet.add(category);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
const piiCategories = piiCatSet.size > 0 ? [...piiCatSet] : void 0;
|
|
2024
2243
|
client.track(EventName.REQUEST, {
|
|
2025
2244
|
ip,
|
|
2026
2245
|
userId: lateUserId,
|
|
@@ -2031,9 +2250,20 @@ function createExpressMiddleware(client) {
|
|
|
2031
2250
|
latencyMs,
|
|
2032
2251
|
userAgent: ua,
|
|
2033
2252
|
bytes,
|
|
2253
|
+
...piiFields.length > 0 ? { piiFields } : {},
|
|
2254
|
+
...piiPatterns.length > 0 ? { piiPatterns } : {},
|
|
2255
|
+
...piiCategories ? { piiCategories } : {},
|
|
2034
2256
|
_fp: fingerprint.score,
|
|
2035
2257
|
_fpSig: fingerprint.signals.join(","),
|
|
2036
2258
|
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
2259
|
+
...browserFp ? {
|
|
2260
|
+
_bfp: browserFp.fp,
|
|
2261
|
+
_bbot: browserFp.bot,
|
|
2262
|
+
_bsigs: browserFp.sigs,
|
|
2263
|
+
...browserFp.pst !== void 0 ? { _bpaste: browserFp.pst } : {},
|
|
2264
|
+
...browserFp.ttf !== void 0 && browserFp.ttf >= 0 ? { _bttf: browserFp.ttf } : {},
|
|
2265
|
+
...browserFp.tts !== void 0 && browserFp.tts >= 0 ? { _btts: browserFp.tts } : {}
|
|
2266
|
+
} : {},
|
|
2037
2267
|
...h2settings ? {
|
|
2038
2268
|
_h2Window: h2settings.initialWindowSize,
|
|
2039
2269
|
_h2HdrTable: h2settings.headerTableSize,
|
|
@@ -2161,14 +2391,32 @@ function createFastifyPlugin(client) {
|
|
|
2161
2391
|
userId,
|
|
2162
2392
|
meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
|
|
2163
2393
|
});
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2394
|
+
const rep = reply;
|
|
2395
|
+
const replyCode = (code) => typeof rep["code"] === "function" ? rep["code"](code) : rep;
|
|
2396
|
+
const replyHeader = (k, v) => {
|
|
2397
|
+
if (typeof rep["header"] === "function") rep["header"](k, v);
|
|
2398
|
+
};
|
|
2399
|
+
const replySend = (chained, body) => {
|
|
2400
|
+
if (chained && typeof chained["send"] === "function") chained["send"](body);
|
|
2401
|
+
};
|
|
2402
|
+
const action = fwMatch.rule.action;
|
|
2403
|
+
if (action === "block" || action === "temporary_block") {
|
|
2404
|
+
replySend(replyCode(403), { error: "Blocked by firewall rule" });
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (action === "rate_limit") {
|
|
2408
|
+
replyHeader("Retry-After", "60");
|
|
2409
|
+
replySend(replyCode(429), { error: "Rate limit exceeded", retryAfter: 60 });
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
if (action === "redirect_honeypot") {
|
|
2413
|
+
const target = fwMatch.rule.redirectTarget ?? "/.env";
|
|
2414
|
+
replyHeader("Location", target);
|
|
2415
|
+
replySend(replyCode(302), "");
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
if (action === "challenge") {
|
|
2419
|
+
replySend(replyCode(403), { error: "Request challenge required" });
|
|
2172
2420
|
return;
|
|
2173
2421
|
}
|
|
2174
2422
|
}
|
|
@@ -2183,11 +2431,11 @@ function createFastifyPlugin(client) {
|
|
|
2183
2431
|
const ip = client.config.getIp(req);
|
|
2184
2432
|
const url = req["url"] ?? "/";
|
|
2185
2433
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
2186
|
-
const
|
|
2434
|
+
const serverUserId = client.config.getUserId(req);
|
|
2187
2435
|
const status = reply["statusCode"] ?? 0;
|
|
2188
2436
|
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2189
2437
|
userIdCheckCount++;
|
|
2190
|
-
if (!
|
|
2438
|
+
if (!serverUserId) userIdMissCount++;
|
|
2191
2439
|
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2192
2440
|
userIdWarnFired = true;
|
|
2193
2441
|
console.warn(
|
|
@@ -2202,6 +2450,28 @@ function createFastifyPlugin(client) {
|
|
|
2202
2450
|
const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
|
|
2203
2451
|
const h2settings = extractHttp2Settings(req);
|
|
2204
2452
|
const upstreamTls = extractUpstreamTls(headers ?? {});
|
|
2453
|
+
const cookieHdrF = headers?.["cookie"];
|
|
2454
|
+
const cookieTokF = cookieHdrF ? cookieHdrF.split(";").map((c) => c.trim()).find((c) => c.startsWith("anomira_fp="))?.slice("anomira_fp=".length) ?? "" : "";
|
|
2455
|
+
const bfpRawF = cookieTokF || headers?.["x-anomira-fp"] || "";
|
|
2456
|
+
let browserFpF = null;
|
|
2457
|
+
if (bfpRawF) {
|
|
2458
|
+
try {
|
|
2459
|
+
const raw = JSON.parse(Buffer.from(bfpRawF, "base64").toString("utf8"));
|
|
2460
|
+
if (typeof raw["v"] === "number" && typeof raw["fp"] === "string") {
|
|
2461
|
+
browserFpF = {
|
|
2462
|
+
fp: raw["fp"],
|
|
2463
|
+
bot: raw["bot"] ?? 0,
|
|
2464
|
+
sigs: (raw["sigs"] ?? []).join(","),
|
|
2465
|
+
uid: typeof raw["uid"] === "string" && raw["uid"] ? raw["uid"] : void 0,
|
|
2466
|
+
pst: raw["frm"]?.pst,
|
|
2467
|
+
ttf: raw["frm"]?.ttf,
|
|
2468
|
+
tts: raw["frm"]?.tts
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
} catch {
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const userId = serverUserId || browserFpF?.uid || "";
|
|
2205
2475
|
client.track(EventName.REQUEST, {
|
|
2206
2476
|
ip,
|
|
2207
2477
|
userId,
|
|
@@ -2215,6 +2485,14 @@ function createFastifyPlugin(client) {
|
|
|
2215
2485
|
_fp: fingerprint.score,
|
|
2216
2486
|
_fpSig: fingerprint.signals.join(","),
|
|
2217
2487
|
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
2488
|
+
...browserFpF ? {
|
|
2489
|
+
_bfp: browserFpF.fp,
|
|
2490
|
+
_bbot: browserFpF.bot,
|
|
2491
|
+
_bsigs: browserFpF.sigs,
|
|
2492
|
+
...browserFpF.pst !== void 0 ? { _bpaste: browserFpF.pst } : {},
|
|
2493
|
+
...browserFpF.ttf !== void 0 && browserFpF.ttf >= 0 ? { _bttf: browserFpF.ttf } : {},
|
|
2494
|
+
...browserFpF.tts !== void 0 && browserFpF.tts >= 0 ? { _btts: browserFpF.tts } : {}
|
|
2495
|
+
} : {},
|
|
2218
2496
|
...h2settings ? {
|
|
2219
2497
|
_h2Window: h2settings.initialWindowSize,
|
|
2220
2498
|
_h2HdrTable: h2settings.headerTableSize,
|