@anomira/node-sdk 0.2.2 → 0.2.4
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 +279 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -2
- package/dist/index.d.ts +22 -2
- package/dist/index.js +279 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -228,13 +228,33 @@ declare class AnomiraClient {
|
|
|
228
228
|
private readonly _origWarn;
|
|
229
229
|
private readonly _origError;
|
|
230
230
|
constructor(config: AnomiraConfig);
|
|
231
|
+
/**
|
|
232
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
233
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
234
|
+
*
|
|
235
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
236
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
237
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
238
|
+
*
|
|
239
|
+
* ```ts
|
|
240
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
241
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
242
|
+
* ip: sentinel.getClientIp(req),
|
|
243
|
+
* userId: req.body.phone,
|
|
244
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
245
|
+
* });
|
|
246
|
+
*
|
|
247
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
248
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
getClientIp(req: unknown): string;
|
|
231
252
|
/**
|
|
232
253
|
* Track a custom security event.
|
|
233
254
|
*
|
|
234
255
|
* ```ts
|
|
235
|
-
* // Track a failed OTP attempt
|
|
236
256
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
237
|
-
* ip: req.ip
|
|
257
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
238
258
|
* userId: req.body.phone,
|
|
239
259
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
240
260
|
* });
|
package/dist/index.d.ts
CHANGED
|
@@ -228,13 +228,33 @@ declare class AnomiraClient {
|
|
|
228
228
|
private readonly _origWarn;
|
|
229
229
|
private readonly _origError;
|
|
230
230
|
constructor(config: AnomiraConfig);
|
|
231
|
+
/**
|
|
232
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
233
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
234
|
+
*
|
|
235
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
236
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
237
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
238
|
+
*
|
|
239
|
+
* ```ts
|
|
240
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
241
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
242
|
+
* ip: sentinel.getClientIp(req),
|
|
243
|
+
* userId: req.body.phone,
|
|
244
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
245
|
+
* });
|
|
246
|
+
*
|
|
247
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
248
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
getClientIp(req: unknown): string;
|
|
231
252
|
/**
|
|
232
253
|
* Track a custom security event.
|
|
233
254
|
*
|
|
234
255
|
* ```ts
|
|
235
|
-
* // Track a failed OTP attempt
|
|
236
256
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
237
|
-
* ip: req.ip
|
|
257
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
238
258
|
* userId: req.body.phone,
|
|
239
259
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
240
260
|
* });
|
package/dist/index.js
CHANGED
|
@@ -517,6 +517,9 @@ var KNOWN_BOT_UA = [
|
|
|
517
517
|
[/^Ruby$/i, "Ruby"]
|
|
518
518
|
];
|
|
519
519
|
var AXIOS_ACCEPT = "application/json, text/plain, */*";
|
|
520
|
+
function isBrowserUa(ua) {
|
|
521
|
+
return /Mozilla\/5\.0/.test(ua) && /(?:Chrome|Firefox|Safari|OPR|Edg(?:e|HTML)?|Trident)\/[\d.]+/.test(ua);
|
|
522
|
+
}
|
|
520
523
|
function computeBrowserFingerprint(headers, ua) {
|
|
521
524
|
const signals = [];
|
|
522
525
|
let score = 0;
|
|
@@ -533,10 +536,15 @@ function computeBrowserFingerprint(headers, ua) {
|
|
|
533
536
|
}
|
|
534
537
|
const accept = str(headers["accept"]);
|
|
535
538
|
if (!isDefiniteBot && accept === AXIOS_ACCEPT) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
539
|
+
if (!isBrowserUa(ua)) {
|
|
540
|
+
isDefiniteBot = true;
|
|
541
|
+
knownClient = "axios";
|
|
542
|
+
score = 100;
|
|
543
|
+
signals.push("known_accept:axios");
|
|
544
|
+
} else {
|
|
545
|
+
score += 15;
|
|
546
|
+
signals.push("browser_axios_accept");
|
|
547
|
+
}
|
|
540
548
|
}
|
|
541
549
|
if (isDefiniteBot) return { score, signals, isDefiniteBot, knownClient };
|
|
542
550
|
const hasSecFetchSite = "sec-fetch-site" in headers;
|
|
@@ -620,7 +628,7 @@ function detectHoneypotType(path) {
|
|
|
620
628
|
function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
|
|
621
629
|
switch (type) {
|
|
622
630
|
case "env_file":
|
|
623
|
-
return makeEnvFile(canaryToken
|
|
631
|
+
return makeEnvFile(canaryToken);
|
|
624
632
|
case "aws_credentials":
|
|
625
633
|
return makeAwsCredentials(canaryToken);
|
|
626
634
|
case "git_config":
|
|
@@ -641,14 +649,16 @@ function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
|
|
|
641
649
|
return makeGeneric(canaryToken);
|
|
642
650
|
}
|
|
643
651
|
}
|
|
644
|
-
function makeEnvFile(canaryToken,
|
|
652
|
+
function makeEnvFile(canaryToken, _callbackBase, _orgId) {
|
|
645
653
|
const jwtSecret = genHex(32);
|
|
646
654
|
const dbPassword = genAlphanumeric(20);
|
|
647
655
|
const redisPassword = genAlphanumeric(16);
|
|
648
656
|
const apiKey = genHex(32);
|
|
649
|
-
const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
|
|
650
657
|
const dnsDb = `db.${canaryToken}.srv.anomira.io`;
|
|
651
658
|
const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
|
|
659
|
+
const dnsMonitoring = `hooks.monitoring.${canaryToken}.srv.anomira.io`;
|
|
660
|
+
const dnsAlerts = `hooks.alerts.${canaryToken}.srv.anomira.io`;
|
|
661
|
+
const dnsDeploy = `deploy.internal.${canaryToken}.srv.anomira.io`;
|
|
652
662
|
const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
|
|
653
663
|
const body = `# Production Environment Configuration
|
|
654
664
|
# Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
|
|
@@ -679,9 +689,9 @@ AWS_REGION=eu-west-1
|
|
|
679
689
|
AWS_S3_BUCKET=app-production-${genHex(4)}
|
|
680
690
|
|
|
681
691
|
# \u2500\u2500 Monitoring / Webhooks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
682
|
-
MONITORING_WEBHOOK
|
|
683
|
-
ALERT_WEBHOOK
|
|
684
|
-
DEPLOY_HOOK
|
|
692
|
+
MONITORING_WEBHOOK=https://${dnsMonitoring}/v1/events/ingest
|
|
693
|
+
ALERT_WEBHOOK=https://${dnsAlerts}/services/${genHex(7).toUpperCase()}/notify
|
|
694
|
+
DEPLOY_HOOK=https://${dnsDeploy}/hooks/deploy/${genHex(6)}
|
|
685
695
|
|
|
686
696
|
# \u2500\u2500 External APIs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
687
697
|
INTERNAL_API_KEY=${apiKey}
|
|
@@ -1093,7 +1103,8 @@ function makeGeneric(canaryToken) {
|
|
|
1093
1103
|
}
|
|
1094
1104
|
function generateCanaryJwt(canaryToken, secret) {
|
|
1095
1105
|
const now = Math.floor(Date.now() / 1e3);
|
|
1096
|
-
const
|
|
1106
|
+
const kid = `${canaryToken.slice(0, 8)}-${canaryToken.slice(8, 12)}-4${canaryToken.slice(13, 16)}-${canaryToken.slice(16, 20)}-${canaryToken.slice(20, 32)}`;
|
|
1107
|
+
const header = { alg: "HS256", typ: "JWT", kid };
|
|
1097
1108
|
const payload = {
|
|
1098
1109
|
sub: "svc_internal_7482",
|
|
1099
1110
|
name: "service-account",
|
|
@@ -1103,7 +1114,8 @@ function generateCanaryJwt(canaryToken, secret) {
|
|
|
1103
1114
|
// issued 48h ago (realistic stale credential)
|
|
1104
1115
|
exp: now - 86400,
|
|
1105
1116
|
// expired 24h ago
|
|
1106
|
-
jti:
|
|
1117
|
+
jti: canaryToken
|
|
1118
|
+
// raw hex — no "canary-" prefix to leak purpose
|
|
1107
1119
|
};
|
|
1108
1120
|
const h = Buffer.from(JSON.stringify(header)).toString("base64url");
|
|
1109
1121
|
const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
@@ -1139,6 +1151,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
|
|
|
1139
1151
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1140
1152
|
var DEFAULT_FLUSH_MS = 5e3;
|
|
1141
1153
|
var DEFAULT_MAX_RETRIES = 3;
|
|
1154
|
+
var SENSITIVE_FIELDS = {
|
|
1155
|
+
identity: [
|
|
1156
|
+
"first_name",
|
|
1157
|
+
"last_name",
|
|
1158
|
+
"full_name",
|
|
1159
|
+
"name",
|
|
1160
|
+
"bvn",
|
|
1161
|
+
"nin",
|
|
1162
|
+
"ssn",
|
|
1163
|
+
"national_id",
|
|
1164
|
+
"tin",
|
|
1165
|
+
"passport",
|
|
1166
|
+
"passport_number",
|
|
1167
|
+
"drivers_license",
|
|
1168
|
+
"license_number",
|
|
1169
|
+
"voter_id",
|
|
1170
|
+
"dob",
|
|
1171
|
+
"date_of_birth",
|
|
1172
|
+
"birth_date",
|
|
1173
|
+
"birthdate",
|
|
1174
|
+
"gender",
|
|
1175
|
+
"marital_status",
|
|
1176
|
+
"nationality"
|
|
1177
|
+
],
|
|
1178
|
+
contact: [
|
|
1179
|
+
"email",
|
|
1180
|
+
"phone",
|
|
1181
|
+
"phone_number",
|
|
1182
|
+
"mobile",
|
|
1183
|
+
"mobile_number",
|
|
1184
|
+
"address",
|
|
1185
|
+
"street",
|
|
1186
|
+
"city",
|
|
1187
|
+
"state",
|
|
1188
|
+
"postal_code",
|
|
1189
|
+
"zip"
|
|
1190
|
+
],
|
|
1191
|
+
financial: [
|
|
1192
|
+
"card_number",
|
|
1193
|
+
"credit_card",
|
|
1194
|
+
"debit_card",
|
|
1195
|
+
"cvv",
|
|
1196
|
+
"account_number",
|
|
1197
|
+
"bank_account",
|
|
1198
|
+
"routing_number",
|
|
1199
|
+
"sort_code",
|
|
1200
|
+
"iban",
|
|
1201
|
+
"swift",
|
|
1202
|
+
"wallet_id"
|
|
1203
|
+
],
|
|
1204
|
+
authentication: [
|
|
1205
|
+
"password",
|
|
1206
|
+
"pin",
|
|
1207
|
+
"otp",
|
|
1208
|
+
"secret",
|
|
1209
|
+
"private_key",
|
|
1210
|
+
"api_key",
|
|
1211
|
+
"access_token",
|
|
1212
|
+
"refresh_token",
|
|
1213
|
+
"jwt",
|
|
1214
|
+
"bearer_token",
|
|
1215
|
+
"security_answer",
|
|
1216
|
+
"mother_maiden_name"
|
|
1217
|
+
],
|
|
1218
|
+
biometric: [
|
|
1219
|
+
"fingerprint",
|
|
1220
|
+
"face_id",
|
|
1221
|
+
"iris",
|
|
1222
|
+
"voiceprint",
|
|
1223
|
+
"biometric"
|
|
1224
|
+
],
|
|
1225
|
+
health: [
|
|
1226
|
+
"medical_record",
|
|
1227
|
+
"diagnosis",
|
|
1228
|
+
"blood_group",
|
|
1229
|
+
"insurance_id"
|
|
1230
|
+
],
|
|
1231
|
+
fintech: [
|
|
1232
|
+
"kyc_id",
|
|
1233
|
+
"customer_id",
|
|
1234
|
+
"beneficiary_account",
|
|
1235
|
+
"transaction_pin",
|
|
1236
|
+
"wallet_balance",
|
|
1237
|
+
"bank_verification_number",
|
|
1238
|
+
"virtual_account",
|
|
1239
|
+
"monnify_account",
|
|
1240
|
+
"paystack_customer",
|
|
1241
|
+
"flutterwave_customer"
|
|
1242
|
+
]
|
|
1243
|
+
};
|
|
1244
|
+
var FIELD_CATEGORY_MAP = /* @__PURE__ */ new Map();
|
|
1245
|
+
for (const [cat, fields] of Object.entries(SENSITIVE_FIELDS)) {
|
|
1246
|
+
for (const f of fields) FIELD_CATEGORY_MAP.set(f, cat);
|
|
1247
|
+
}
|
|
1248
|
+
var VALUE_PATTERNS = [
|
|
1249
|
+
{ name: "email", category: "contact", re: /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ },
|
|
1250
|
+
{ name: "bvn_nin", category: "identity", re: /^\d{11}$/ },
|
|
1251
|
+
{ name: "card_number", category: "financial", re: /^\d{13,19}$/ },
|
|
1252
|
+
{ name: "phone_ng", category: "contact", re: /^(?:\+234|0)[789][01]\d{8}$/ },
|
|
1253
|
+
{ name: "jwt", category: "authentication", re: /^eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\./ },
|
|
1254
|
+
{ name: "iban", category: "financial", re: /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/ }
|
|
1255
|
+
];
|
|
1142
1256
|
var AnomiraClient = class {
|
|
1143
1257
|
constructor(config) {
|
|
1144
1258
|
this.logBuffer = [];
|
|
@@ -1471,13 +1585,35 @@ var AnomiraClient = class {
|
|
|
1471
1585
|
}
|
|
1472
1586
|
}
|
|
1473
1587
|
// ─── Public API ────────────────────────────────────────────────────────────
|
|
1588
|
+
/**
|
|
1589
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
1590
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
1591
|
+
*
|
|
1592
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
1593
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
1594
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
1595
|
+
*
|
|
1596
|
+
* ```ts
|
|
1597
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
1598
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
1599
|
+
* ip: sentinel.getClientIp(req),
|
|
1600
|
+
* userId: req.body.phone,
|
|
1601
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
1602
|
+
* });
|
|
1603
|
+
*
|
|
1604
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
1605
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
1606
|
+
* ```
|
|
1607
|
+
*/
|
|
1608
|
+
getClientIp(req) {
|
|
1609
|
+
return this.config.getIp(req);
|
|
1610
|
+
}
|
|
1474
1611
|
/**
|
|
1475
1612
|
* Track a custom security event.
|
|
1476
1613
|
*
|
|
1477
1614
|
* ```ts
|
|
1478
|
-
* // Track a failed OTP attempt
|
|
1479
1615
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
1480
|
-
* ip: req.ip
|
|
1616
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
1481
1617
|
* userId: req.body.phone,
|
|
1482
1618
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
1483
1619
|
* });
|
|
@@ -1550,6 +1686,11 @@ var AnomiraClient = class {
|
|
|
1550
1686
|
if (this.disabled) return;
|
|
1551
1687
|
const ctx = requestContext.getStore();
|
|
1552
1688
|
const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
|
|
1689
|
+
if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
|
|
1690
|
+
this._origWarn(
|
|
1691
|
+
`[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.`
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1553
1694
|
const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
|
|
1554
1695
|
const event = {
|
|
1555
1696
|
name: eventName,
|
|
@@ -1765,6 +1906,12 @@ function defaultGetUserId(req) {
|
|
|
1765
1906
|
);
|
|
1766
1907
|
const jwtId = pickId(payload);
|
|
1767
1908
|
if (jwtId) return jwtId;
|
|
1909
|
+
for (const val of Object.values(payload)) {
|
|
1910
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
1911
|
+
const nested = pickId(val);
|
|
1912
|
+
if (nested) return nested;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1768
1915
|
}
|
|
1769
1916
|
} catch {
|
|
1770
1917
|
}
|
|
@@ -1784,18 +1931,27 @@ function normalizeIp(raw) {
|
|
|
1784
1931
|
if (raw.startsWith("::ffff:")) return raw.slice(7);
|
|
1785
1932
|
return raw;
|
|
1786
1933
|
}
|
|
1934
|
+
function isPrivateIp2(ip) {
|
|
1935
|
+
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);
|
|
1936
|
+
}
|
|
1787
1937
|
function defaultGetIp(req) {
|
|
1788
1938
|
const r = req;
|
|
1789
1939
|
const fwd = r["headers"];
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1940
|
+
function firstHdr(name) {
|
|
1941
|
+
const v = fwd?.[name];
|
|
1942
|
+
if (!v) return void 0;
|
|
1943
|
+
const s = (Array.isArray(v) ? v[0] : v.split(",")[0])?.trim();
|
|
1944
|
+
return s || void 0;
|
|
1794
1945
|
}
|
|
1795
|
-
const
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1946
|
+
const ip = firstHdr("cf-connecting-ip") ?? // Cloudflare
|
|
1947
|
+
firstHdr("true-client-ip") ?? // Cloudflare Enterprise / Akamai
|
|
1948
|
+
firstHdr("x-forwarded-for") ?? // Nginx, AWS ALB, GCP LB, most proxies
|
|
1949
|
+
firstHdr("x-real-ip") ?? // Nginx (single-IP alternative to XFF)
|
|
1950
|
+
firstHdr("fastly-client-ip") ?? // Fastly CDN
|
|
1951
|
+
firstHdr("x-client-ip") ?? // Generic reverse proxies
|
|
1952
|
+
firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
|
|
1953
|
+
r["socket"]?.remoteAddress ?? "0.0.0.0";
|
|
1954
|
+
return normalizeIp(ip);
|
|
1799
1955
|
}
|
|
1800
1956
|
function sendFakeResponse(res, fakeResponse) {
|
|
1801
1957
|
const allHeaders = {
|
|
@@ -1818,10 +1974,29 @@ function sendFakeResponse(res, fakeResponse) {
|
|
|
1818
1974
|
res["end"](fakeResponse.body);
|
|
1819
1975
|
}
|
|
1820
1976
|
}
|
|
1977
|
+
function isLoopbackIp(ip) {
|
|
1978
|
+
return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "::1";
|
|
1979
|
+
}
|
|
1821
1980
|
function createExpressMiddleware(client) {
|
|
1981
|
+
let ipCheckCount = 0;
|
|
1982
|
+
let ipLoopCount = 0;
|
|
1983
|
+
let ipWarnFired = false;
|
|
1984
|
+
let userIdCheckCount = 0;
|
|
1985
|
+
let userIdMissCount = 0;
|
|
1986
|
+
let userIdWarnFired = false;
|
|
1822
1987
|
return async function sentinelMiddleware(req, res, next) {
|
|
1823
1988
|
const startMs = Date.now();
|
|
1824
1989
|
const ip = client.config.getIp(req);
|
|
1990
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
1991
|
+
ipCheckCount++;
|
|
1992
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
1993
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
1994
|
+
ipWarnFired = true;
|
|
1995
|
+
console.warn(
|
|
1996
|
+
"[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Express fix: app.set('trust proxy', 1);\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
1825
2000
|
if (client.isBlocked(ip)) {
|
|
1826
2001
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
1827
2002
|
const url_ = req["originalUrl"] ?? req["url"] ?? "/";
|
|
@@ -1878,12 +2053,12 @@ function createExpressMiddleware(client) {
|
|
|
1878
2053
|
const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
1879
2054
|
const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
|
|
1880
2055
|
const jti = pay["jti"] ?? "";
|
|
1881
|
-
const
|
|
1882
|
-
if (jti
|
|
2056
|
+
const cache = client["canaryTokenCache"];
|
|
2057
|
+
if (jti && cache.has(jti)) {
|
|
1883
2058
|
client.track("http.canary.triggered", {
|
|
1884
2059
|
ip,
|
|
1885
2060
|
userId,
|
|
1886
|
-
meta: { endpoint: url, method, token: jti.slice(
|
|
2061
|
+
meta: { endpoint: url, method, token: jti.slice(0, 16), source: "canary_jwt" }
|
|
1887
2062
|
});
|
|
1888
2063
|
}
|
|
1889
2064
|
}
|
|
@@ -1971,13 +2146,48 @@ function createExpressMiddleware(client) {
|
|
|
1971
2146
|
}
|
|
1972
2147
|
}
|
|
1973
2148
|
const onFinish = () => {
|
|
2149
|
+
const lateUserId = client.config.getUserId(req);
|
|
2150
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2151
|
+
userIdCheckCount++;
|
|
2152
|
+
if (!lateUserId) userIdMissCount++;
|
|
2153
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2154
|
+
userIdWarnFired = true;
|
|
2155
|
+
console.warn(
|
|
2156
|
+
"[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / express-jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => req.user?.id })"
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
1974
2160
|
const status = res["statusCode"] ?? 0;
|
|
1975
2161
|
const latencyMs = Date.now() - startMs;
|
|
1976
2162
|
const getHeader = res["getHeader"];
|
|
1977
2163
|
const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
|
|
2164
|
+
const rawBody = req["body"];
|
|
2165
|
+
const piiFields = [];
|
|
2166
|
+
const piiPatterns = [];
|
|
2167
|
+
const piiCatSet = /* @__PURE__ */ new Set();
|
|
2168
|
+
if (rawBody != null && typeof rawBody === "object" && !Array.isArray(rawBody)) {
|
|
2169
|
+
const body = rawBody;
|
|
2170
|
+
for (const key of Object.keys(body)) {
|
|
2171
|
+
const cat = FIELD_CATEGORY_MAP.get(key.toLowerCase());
|
|
2172
|
+
if (cat) {
|
|
2173
|
+
piiFields.push(key.toLowerCase());
|
|
2174
|
+
piiCatSet.add(cat);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
for (const val of Object.values(body)) {
|
|
2178
|
+
if (typeof val !== "string" || val.length > 200) continue;
|
|
2179
|
+
for (const { name, category, re } of VALUE_PATTERNS) {
|
|
2180
|
+
if (!piiPatterns.includes(name) && re.test(val)) {
|
|
2181
|
+
piiPatterns.push(name);
|
|
2182
|
+
piiCatSet.add(category);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
const piiCategories = piiCatSet.size > 0 ? [...piiCatSet] : void 0;
|
|
1978
2188
|
client.track(EventName.REQUEST, {
|
|
1979
2189
|
ip,
|
|
1980
|
-
userId,
|
|
2190
|
+
userId: lateUserId,
|
|
1981
2191
|
meta: {
|
|
1982
2192
|
method,
|
|
1983
2193
|
endpoint: url,
|
|
@@ -1985,17 +2195,18 @@ function createExpressMiddleware(client) {
|
|
|
1985
2195
|
latencyMs,
|
|
1986
2196
|
userAgent: ua,
|
|
1987
2197
|
bytes,
|
|
2198
|
+
...piiFields.length > 0 ? { piiFields } : {},
|
|
2199
|
+
...piiPatterns.length > 0 ? { piiPatterns } : {},
|
|
2200
|
+
...piiCategories ? { piiCategories } : {},
|
|
1988
2201
|
_fp: fingerprint.score,
|
|
1989
2202
|
_fpSig: fingerprint.signals.join(","),
|
|
1990
2203
|
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
1991
|
-
// HTTP/2 SETTINGS from client connection preface (when Node.js is TLS endpoint)
|
|
1992
2204
|
...h2settings ? {
|
|
1993
2205
|
_h2Window: h2settings.initialWindowSize,
|
|
1994
2206
|
_h2HdrTable: h2settings.headerTableSize,
|
|
1995
2207
|
_h2Score: h2settings.score,
|
|
1996
2208
|
_h2Signals: h2settings.signals.join(",")
|
|
1997
2209
|
} : {},
|
|
1998
|
-
// JA3/JA4 from upstream proxy (Nginx with ngx_ssl_fingerprint_module)
|
|
1999
2210
|
...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
|
|
2000
2211
|
...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
|
|
2001
2212
|
...agentInfo.isAgent ? {
|
|
@@ -2012,7 +2223,7 @@ function createExpressMiddleware(client) {
|
|
|
2012
2223
|
if (agentInfo.isAgent) {
|
|
2013
2224
|
client.track("http.agent_detected", {
|
|
2014
2225
|
ip,
|
|
2015
|
-
userId,
|
|
2226
|
+
userId: lateUserId,
|
|
2016
2227
|
meta: {
|
|
2017
2228
|
endpoint: url,
|
|
2018
2229
|
method,
|
|
@@ -2028,17 +2239,17 @@ function createExpressMiddleware(client) {
|
|
|
2028
2239
|
});
|
|
2029
2240
|
}
|
|
2030
2241
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
2031
|
-
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2242
|
+
client.track(EventName.RATE_LIMIT, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
2032
2243
|
}
|
|
2033
2244
|
if (client.config.detect.bruteForce && status === 401) {
|
|
2034
2245
|
if (/\/(login|signin|auth|token|session)/i.test(url)) {
|
|
2035
|
-
client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2246
|
+
client.track(EventName.LOGIN_FAILED, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
2036
2247
|
}
|
|
2037
2248
|
}
|
|
2038
2249
|
if (client.config.detect.scanDetection && status === 404) {
|
|
2039
2250
|
const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
|
|
2040
2251
|
if (looksLikeScanner) {
|
|
2041
|
-
client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
|
|
2252
|
+
client.track(EventName.SCAN_DETECTED, { ip, userId: lateUserId, meta: { url, method, userAgent: ua } });
|
|
2042
2253
|
}
|
|
2043
2254
|
}
|
|
2044
2255
|
cleanup();
|
|
@@ -2051,9 +2262,25 @@ function createExpressMiddleware(client) {
|
|
|
2051
2262
|
};
|
|
2052
2263
|
}
|
|
2053
2264
|
function createFastifyPlugin(client) {
|
|
2265
|
+
let ipCheckCount = 0;
|
|
2266
|
+
let ipLoopCount = 0;
|
|
2267
|
+
let ipWarnFired = false;
|
|
2268
|
+
let userIdCheckCount = 0;
|
|
2269
|
+
let userIdMissCount = 0;
|
|
2270
|
+
let userIdWarnFired = false;
|
|
2054
2271
|
return async function sentinelFastifyPlugin(fastify) {
|
|
2055
2272
|
fastify.addHook("onRequest", (req, reply) => {
|
|
2056
2273
|
const ip = client.config.getIp(req);
|
|
2274
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
2275
|
+
ipCheckCount++;
|
|
2276
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
2277
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
2278
|
+
ipWarnFired = true;
|
|
2279
|
+
console.warn(
|
|
2280
|
+
"[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2057
2284
|
const url_ = req["url"] ?? "/";
|
|
2058
2285
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
2059
2286
|
const hdrs_ = req["headers"];
|
|
@@ -2125,6 +2352,16 @@ function createFastifyPlugin(client) {
|
|
|
2125
2352
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
2126
2353
|
const userId = client.config.getUserId(req);
|
|
2127
2354
|
const status = reply["statusCode"] ?? 0;
|
|
2355
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2356
|
+
userIdCheckCount++;
|
|
2357
|
+
if (!userId) userIdMissCount++;
|
|
2358
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2359
|
+
userIdWarnFired = true;
|
|
2360
|
+
console.warn(
|
|
2361
|
+
"[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / @fastify/jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => (req as any).user?.id })"
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2128
2365
|
const headers = req["headers"];
|
|
2129
2366
|
const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
|
|
2130
2367
|
const latencyMs = reply["elapsedTime"] ?? 0;
|
|
@@ -2208,6 +2445,11 @@ function createExpressMiddleware2(client) {
|
|
|
2208
2445
|
const ip = client.config.getIp(req);
|
|
2209
2446
|
const userId = client.config.getUserId(req);
|
|
2210
2447
|
const { method, originalUrl: url } = req;
|
|
2448
|
+
if (client.isBlocked(ip)) {
|
|
2449
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2450
|
+
res.status(403).json({ error: "Forbidden" });
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2211
2453
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
2212
2454
|
client.track(EventName.PATH_TRAVERSAL, {
|
|
2213
2455
|
ip,
|
|
@@ -2272,11 +2514,15 @@ function createExpressMiddleware2(client) {
|
|
|
2272
2514
|
// src/middleware/fastify.ts
|
|
2273
2515
|
function createFastifyPlugin2(client) {
|
|
2274
2516
|
return async function sentinelPlugin(fastify) {
|
|
2275
|
-
fastify.addHook("onRequest", async (req,
|
|
2517
|
+
fastify.addHook("onRequest", async (req, reply) => {
|
|
2276
2518
|
const ip = client.config.getIp(req);
|
|
2277
2519
|
const userId = client.config.getUserId(req);
|
|
2278
2520
|
const url = req.url;
|
|
2279
2521
|
const method = req.method.toUpperCase();
|
|
2522
|
+
if (client.isBlocked(ip)) {
|
|
2523
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2524
|
+
return reply.code(403).send({ error: "Forbidden" });
|
|
2525
|
+
}
|
|
2280
2526
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
2281
2527
|
client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
|
|
2282
2528
|
}
|