@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/README.md
CHANGED
|
@@ -168,41 +168,70 @@ Once registered, the middleware records **every request** with zero additional c
|
|
|
168
168
|
|
|
169
169
|
Use these when the middleware can't infer the event on its own — e.g., application-level failures.
|
|
170
170
|
|
|
171
|
+
### Getting the real client IP
|
|
172
|
+
|
|
173
|
+
> **Important — do not use `req.ip` for the `ip` field.**
|
|
174
|
+
>
|
|
175
|
+
> Behind a reverse proxy (Nginx, AWS ALB, Cloudflare — which every production app uses), `req.ip` in Express without `trust proxy` configured returns `127.0.0.1` — the proxy's address, not the user's. This breaks geo-detection, IP blocking, and attack correlation.
|
|
176
|
+
>
|
|
177
|
+
> Always use `anomira.getClientIp(req)` instead. It reads `X-Forwarded-For`, `CF-Connecting-IP`, `X-Real-IP`, and other forwarded headers in the correct priority order automatically.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// ✅ Correct — works behind Nginx, Cloudflare, AWS ALB, any reverse proxy
|
|
181
|
+
const ip = anomira.getClientIp(req);
|
|
182
|
+
|
|
183
|
+
// ❌ Wrong behind a proxy — returns 127.0.0.1 unless trust proxy is configured
|
|
184
|
+
const ip = req.ip;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If you use `anomira.express()` or `anomira.fastify()` middleware and call `track()` **inside a request handler**, you can omit the `ip` field entirely — the SDK reads it from the active request context automatically.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// ✅ Also correct — IP is resolved from middleware context, no need to pass it
|
|
191
|
+
anomira.track("auth.otp.failed", { userId: req.body.phone });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Examples
|
|
195
|
+
|
|
171
196
|
```ts
|
|
172
197
|
// Credential stuffing / failed login attempt
|
|
173
198
|
await anomira.trackLogin({
|
|
174
|
-
ip: req
|
|
199
|
+
ip: anomira.getClientIp(req),
|
|
175
200
|
userId: req.body.email, // email or user ID
|
|
176
201
|
success: false, // false = failed attempt
|
|
177
202
|
});
|
|
178
203
|
|
|
179
204
|
// Successful login (enables geo-velocity tracking)
|
|
180
205
|
await anomira.trackLogin({
|
|
181
|
-
ip:
|
|
182
|
-
userId:
|
|
206
|
+
ip: anomira.getClientIp(req),
|
|
207
|
+
userId: user.id,
|
|
183
208
|
success: true,
|
|
184
209
|
});
|
|
185
210
|
|
|
186
211
|
// Failed OTP — otp_flood detection
|
|
187
212
|
anomira.track("auth.otp.failed", {
|
|
188
|
-
ip: req
|
|
213
|
+
ip: anomira.getClientIp(req),
|
|
189
214
|
userId: req.body.phone,
|
|
190
215
|
meta: { endpoint: "/api/verify-otp" },
|
|
191
216
|
});
|
|
192
217
|
|
|
193
218
|
// SIM swap detection — call after any phone-based auth
|
|
194
|
-
anomira.trackPhoneAuth({
|
|
219
|
+
anomira.trackPhoneAuth({
|
|
220
|
+
ip: anomira.getClientIp(req),
|
|
221
|
+
userId: user.id,
|
|
222
|
+
phone: user.phone,
|
|
223
|
+
});
|
|
195
224
|
|
|
196
225
|
// Account takeover signal — e.g., password changed from new IP
|
|
197
226
|
anomira.track("auth.account.takeover", {
|
|
198
|
-
ip: req
|
|
227
|
+
ip: anomira.getClientIp(req),
|
|
199
228
|
userId: user.id,
|
|
200
229
|
meta: { reason: "password_changed_new_ip" },
|
|
201
230
|
});
|
|
202
231
|
|
|
203
232
|
// Webhook replay — call when you detect a replayed webhook signature
|
|
204
233
|
anomira.track("webhook.replay.detected", {
|
|
205
|
-
ip: req
|
|
234
|
+
ip: anomira.getClientIp(req),
|
|
206
235
|
meta: { webhookId: req.headers["x-webhook-id"] },
|
|
207
236
|
});
|
|
208
237
|
```
|
|
@@ -230,8 +259,10 @@ The SDK syncs your dashboard's blocked IPs and firewall rules every 60 seconds.
|
|
|
230
259
|
|
|
231
260
|
```ts
|
|
232
261
|
app.use((req, res, next) => {
|
|
262
|
+
const ip = anomira.getClientIp(req); // always use this, not req.ip
|
|
263
|
+
|
|
233
264
|
// Check if IP is manually blocked in the dashboard
|
|
234
|
-
if (anomira.isBlocked(
|
|
265
|
+
if (anomira.isBlocked(ip)) {
|
|
235
266
|
return res.status(403).json({ error: "Forbidden" });
|
|
236
267
|
}
|
|
237
268
|
|
|
@@ -241,7 +272,7 @@ app.use((req, res, next) => {
|
|
|
241
272
|
method: req.method,
|
|
242
273
|
body: req.body,
|
|
243
274
|
headers: req.headers,
|
|
244
|
-
ip
|
|
275
|
+
ip,
|
|
245
276
|
});
|
|
246
277
|
|
|
247
278
|
if (match) {
|
|
@@ -379,8 +410,8 @@ app.addHook("onClose", async () => {
|
|
|
379
410
|
3. Confirm the middleware is registered **before** your routes and **after** body parsers.
|
|
380
411
|
4. Make sure you're not blocking outbound HTTPS traffic to `api.anomira.io`.
|
|
381
412
|
|
|
382
|
-
**TypeScript errors on
|
|
383
|
-
|
|
413
|
+
**TypeScript errors on IP extraction?**
|
|
414
|
+
Use `anomira.getClientIp(req)` — it handles all proxy headers and TypeScript types correctly. Do not use `req.ip` or `req.socket.remoteAddress` directly in production; both return the proxy address behind Nginx.
|
|
384
415
|
|
|
385
416
|
**`flush()` taking too long on shutdown?**
|
|
386
417
|
The flush waits for in-flight HTTP requests to complete. If your process needs to exit fast, you can add a timeout:
|
package/dist/index.cjs
CHANGED
|
@@ -521,6 +521,9 @@ var KNOWN_BOT_UA = [
|
|
|
521
521
|
[/^Ruby$/i, "Ruby"]
|
|
522
522
|
];
|
|
523
523
|
var AXIOS_ACCEPT = "application/json, text/plain, */*";
|
|
524
|
+
function isBrowserUa(ua) {
|
|
525
|
+
return /Mozilla\/5\.0/.test(ua) && /(?:Chrome|Firefox|Safari|OPR|Edg(?:e|HTML)?|Trident)\/[\d.]+/.test(ua);
|
|
526
|
+
}
|
|
524
527
|
function computeBrowserFingerprint(headers, ua) {
|
|
525
528
|
const signals = [];
|
|
526
529
|
let score = 0;
|
|
@@ -537,10 +540,15 @@ function computeBrowserFingerprint(headers, ua) {
|
|
|
537
540
|
}
|
|
538
541
|
const accept = str(headers["accept"]);
|
|
539
542
|
if (!isDefiniteBot && accept === AXIOS_ACCEPT) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
543
|
+
if (!isBrowserUa(ua)) {
|
|
544
|
+
isDefiniteBot = true;
|
|
545
|
+
knownClient = "axios";
|
|
546
|
+
score = 100;
|
|
547
|
+
signals.push("known_accept:axios");
|
|
548
|
+
} else {
|
|
549
|
+
score += 15;
|
|
550
|
+
signals.push("browser_axios_accept");
|
|
551
|
+
}
|
|
544
552
|
}
|
|
545
553
|
if (isDefiniteBot) return { score, signals, isDefiniteBot, knownClient };
|
|
546
554
|
const hasSecFetchSite = "sec-fetch-site" in headers;
|
|
@@ -624,7 +632,7 @@ function detectHoneypotType(path) {
|
|
|
624
632
|
function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
|
|
625
633
|
switch (type) {
|
|
626
634
|
case "env_file":
|
|
627
|
-
return makeEnvFile(canaryToken
|
|
635
|
+
return makeEnvFile(canaryToken);
|
|
628
636
|
case "aws_credentials":
|
|
629
637
|
return makeAwsCredentials(canaryToken);
|
|
630
638
|
case "git_config":
|
|
@@ -645,14 +653,16 @@ function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
|
|
|
645
653
|
return makeGeneric(canaryToken);
|
|
646
654
|
}
|
|
647
655
|
}
|
|
648
|
-
function makeEnvFile(canaryToken,
|
|
656
|
+
function makeEnvFile(canaryToken, _callbackBase, _orgId) {
|
|
649
657
|
const jwtSecret = genHex(32);
|
|
650
658
|
const dbPassword = genAlphanumeric(20);
|
|
651
659
|
const redisPassword = genAlphanumeric(16);
|
|
652
660
|
const apiKey = genHex(32);
|
|
653
|
-
const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
|
|
654
661
|
const dnsDb = `db.${canaryToken}.srv.anomira.io`;
|
|
655
662
|
const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
|
|
663
|
+
const dnsMonitoring = `hooks.monitoring.${canaryToken}.srv.anomira.io`;
|
|
664
|
+
const dnsAlerts = `hooks.alerts.${canaryToken}.srv.anomira.io`;
|
|
665
|
+
const dnsDeploy = `deploy.internal.${canaryToken}.srv.anomira.io`;
|
|
656
666
|
const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
|
|
657
667
|
const body = `# Production Environment Configuration
|
|
658
668
|
# Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
|
|
@@ -683,9 +693,9 @@ AWS_REGION=eu-west-1
|
|
|
683
693
|
AWS_S3_BUCKET=app-production-${genHex(4)}
|
|
684
694
|
|
|
685
695
|
# \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
|
|
686
|
-
MONITORING_WEBHOOK
|
|
687
|
-
ALERT_WEBHOOK
|
|
688
|
-
DEPLOY_HOOK
|
|
696
|
+
MONITORING_WEBHOOK=https://${dnsMonitoring}/v1/events/ingest
|
|
697
|
+
ALERT_WEBHOOK=https://${dnsAlerts}/services/${genHex(7).toUpperCase()}/notify
|
|
698
|
+
DEPLOY_HOOK=https://${dnsDeploy}/hooks/deploy/${genHex(6)}
|
|
689
699
|
|
|
690
700
|
# \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
|
|
691
701
|
INTERNAL_API_KEY=${apiKey}
|
|
@@ -1097,7 +1107,8 @@ function makeGeneric(canaryToken) {
|
|
|
1097
1107
|
}
|
|
1098
1108
|
function generateCanaryJwt(canaryToken, secret) {
|
|
1099
1109
|
const now = Math.floor(Date.now() / 1e3);
|
|
1100
|
-
const
|
|
1110
|
+
const kid = `${canaryToken.slice(0, 8)}-${canaryToken.slice(8, 12)}-4${canaryToken.slice(13, 16)}-${canaryToken.slice(16, 20)}-${canaryToken.slice(20, 32)}`;
|
|
1111
|
+
const header = { alg: "HS256", typ: "JWT", kid };
|
|
1101
1112
|
const payload = {
|
|
1102
1113
|
sub: "svc_internal_7482",
|
|
1103
1114
|
name: "service-account",
|
|
@@ -1107,7 +1118,8 @@ function generateCanaryJwt(canaryToken, secret) {
|
|
|
1107
1118
|
// issued 48h ago (realistic stale credential)
|
|
1108
1119
|
exp: now - 86400,
|
|
1109
1120
|
// expired 24h ago
|
|
1110
|
-
jti:
|
|
1121
|
+
jti: canaryToken
|
|
1122
|
+
// raw hex — no "canary-" prefix to leak purpose
|
|
1111
1123
|
};
|
|
1112
1124
|
const h = Buffer.from(JSON.stringify(header)).toString("base64url");
|
|
1113
1125
|
const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
@@ -1143,6 +1155,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
|
|
|
1143
1155
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1144
1156
|
var DEFAULT_FLUSH_MS = 5e3;
|
|
1145
1157
|
var DEFAULT_MAX_RETRIES = 3;
|
|
1158
|
+
var SENSITIVE_FIELDS = {
|
|
1159
|
+
identity: [
|
|
1160
|
+
"first_name",
|
|
1161
|
+
"last_name",
|
|
1162
|
+
"full_name",
|
|
1163
|
+
"name",
|
|
1164
|
+
"bvn",
|
|
1165
|
+
"nin",
|
|
1166
|
+
"ssn",
|
|
1167
|
+
"national_id",
|
|
1168
|
+
"tin",
|
|
1169
|
+
"passport",
|
|
1170
|
+
"passport_number",
|
|
1171
|
+
"drivers_license",
|
|
1172
|
+
"license_number",
|
|
1173
|
+
"voter_id",
|
|
1174
|
+
"dob",
|
|
1175
|
+
"date_of_birth",
|
|
1176
|
+
"birth_date",
|
|
1177
|
+
"birthdate",
|
|
1178
|
+
"gender",
|
|
1179
|
+
"marital_status",
|
|
1180
|
+
"nationality"
|
|
1181
|
+
],
|
|
1182
|
+
contact: [
|
|
1183
|
+
"email",
|
|
1184
|
+
"phone",
|
|
1185
|
+
"phone_number",
|
|
1186
|
+
"mobile",
|
|
1187
|
+
"mobile_number",
|
|
1188
|
+
"address",
|
|
1189
|
+
"street",
|
|
1190
|
+
"city",
|
|
1191
|
+
"state",
|
|
1192
|
+
"postal_code",
|
|
1193
|
+
"zip"
|
|
1194
|
+
],
|
|
1195
|
+
financial: [
|
|
1196
|
+
"card_number",
|
|
1197
|
+
"credit_card",
|
|
1198
|
+
"debit_card",
|
|
1199
|
+
"cvv",
|
|
1200
|
+
"account_number",
|
|
1201
|
+
"bank_account",
|
|
1202
|
+
"routing_number",
|
|
1203
|
+
"sort_code",
|
|
1204
|
+
"iban",
|
|
1205
|
+
"swift",
|
|
1206
|
+
"wallet_id"
|
|
1207
|
+
],
|
|
1208
|
+
authentication: [
|
|
1209
|
+
"password",
|
|
1210
|
+
"pin",
|
|
1211
|
+
"otp",
|
|
1212
|
+
"secret",
|
|
1213
|
+
"private_key",
|
|
1214
|
+
"api_key",
|
|
1215
|
+
"access_token",
|
|
1216
|
+
"refresh_token",
|
|
1217
|
+
"jwt",
|
|
1218
|
+
"bearer_token",
|
|
1219
|
+
"security_answer",
|
|
1220
|
+
"mother_maiden_name"
|
|
1221
|
+
],
|
|
1222
|
+
biometric: [
|
|
1223
|
+
"fingerprint",
|
|
1224
|
+
"face_id",
|
|
1225
|
+
"iris",
|
|
1226
|
+
"voiceprint",
|
|
1227
|
+
"biometric"
|
|
1228
|
+
],
|
|
1229
|
+
health: [
|
|
1230
|
+
"medical_record",
|
|
1231
|
+
"diagnosis",
|
|
1232
|
+
"blood_group",
|
|
1233
|
+
"insurance_id"
|
|
1234
|
+
],
|
|
1235
|
+
fintech: [
|
|
1236
|
+
"kyc_id",
|
|
1237
|
+
"customer_id",
|
|
1238
|
+
"beneficiary_account",
|
|
1239
|
+
"transaction_pin",
|
|
1240
|
+
"wallet_balance",
|
|
1241
|
+
"bank_verification_number",
|
|
1242
|
+
"virtual_account",
|
|
1243
|
+
"monnify_account",
|
|
1244
|
+
"paystack_customer",
|
|
1245
|
+
"flutterwave_customer"
|
|
1246
|
+
]
|
|
1247
|
+
};
|
|
1248
|
+
var FIELD_CATEGORY_MAP = /* @__PURE__ */ new Map();
|
|
1249
|
+
for (const [cat, fields] of Object.entries(SENSITIVE_FIELDS)) {
|
|
1250
|
+
for (const f of fields) FIELD_CATEGORY_MAP.set(f, cat);
|
|
1251
|
+
}
|
|
1252
|
+
var VALUE_PATTERNS = [
|
|
1253
|
+
{ name: "email", category: "contact", re: /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ },
|
|
1254
|
+
{ name: "bvn_nin", category: "identity", re: /^\d{11}$/ },
|
|
1255
|
+
{ name: "card_number", category: "financial", re: /^\d{13,19}$/ },
|
|
1256
|
+
{ name: "phone_ng", category: "contact", re: /^(?:\+234|0)[789][01]\d{8}$/ },
|
|
1257
|
+
{ name: "jwt", category: "authentication", re: /^eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\./ },
|
|
1258
|
+
{ name: "iban", category: "financial", re: /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/ }
|
|
1259
|
+
];
|
|
1146
1260
|
var AnomiraClient = class {
|
|
1147
1261
|
constructor(config) {
|
|
1148
1262
|
this.logBuffer = [];
|
|
@@ -1475,13 +1589,35 @@ var AnomiraClient = class {
|
|
|
1475
1589
|
}
|
|
1476
1590
|
}
|
|
1477
1591
|
// ─── Public API ────────────────────────────────────────────────────────────
|
|
1592
|
+
/**
|
|
1593
|
+
* Extract the real client IP from a request object, reading forwarded headers
|
|
1594
|
+
* in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
|
|
1595
|
+
*
|
|
1596
|
+
* Use this instead of `req.ip` when tracking events manually. `req.ip` in
|
|
1597
|
+
* Express without `trust proxy` set returns the proxy's address (127.0.0.1
|
|
1598
|
+
* behind Nginx), which breaks geo-detection and IP blocking.
|
|
1599
|
+
*
|
|
1600
|
+
* ```ts
|
|
1601
|
+
* // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
|
|
1602
|
+
* sentinel.track(EventName.OTP_FAILED, {
|
|
1603
|
+
* ip: sentinel.getClientIp(req),
|
|
1604
|
+
* userId: req.body.phone,
|
|
1605
|
+
* meta: { endpoint: "/api/verify-otp" },
|
|
1606
|
+
* });
|
|
1607
|
+
*
|
|
1608
|
+
* // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
|
|
1609
|
+
* sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
|
|
1610
|
+
* ```
|
|
1611
|
+
*/
|
|
1612
|
+
getClientIp(req) {
|
|
1613
|
+
return this.config.getIp(req);
|
|
1614
|
+
}
|
|
1478
1615
|
/**
|
|
1479
1616
|
* Track a custom security event.
|
|
1480
1617
|
*
|
|
1481
1618
|
* ```ts
|
|
1482
|
-
* // Track a failed OTP attempt
|
|
1483
1619
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
1484
|
-
* ip: req.ip
|
|
1620
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
1485
1621
|
* userId: req.body.phone,
|
|
1486
1622
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
1487
1623
|
* });
|
|
@@ -1554,6 +1690,11 @@ var AnomiraClient = class {
|
|
|
1554
1690
|
if (this.disabled) return;
|
|
1555
1691
|
const ctx = requestContext.getStore();
|
|
1556
1692
|
const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
|
|
1693
|
+
if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
|
|
1694
|
+
this._origWarn(
|
|
1695
|
+
`[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.`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1557
1698
|
const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
|
|
1558
1699
|
const event = {
|
|
1559
1700
|
name: eventName,
|
|
@@ -1769,6 +1910,12 @@ function defaultGetUserId(req) {
|
|
|
1769
1910
|
);
|
|
1770
1911
|
const jwtId = pickId(payload);
|
|
1771
1912
|
if (jwtId) return jwtId;
|
|
1913
|
+
for (const val of Object.values(payload)) {
|
|
1914
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
1915
|
+
const nested = pickId(val);
|
|
1916
|
+
if (nested) return nested;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1772
1919
|
}
|
|
1773
1920
|
} catch {
|
|
1774
1921
|
}
|
|
@@ -1788,18 +1935,27 @@ function normalizeIp(raw) {
|
|
|
1788
1935
|
if (raw.startsWith("::ffff:")) return raw.slice(7);
|
|
1789
1936
|
return raw;
|
|
1790
1937
|
}
|
|
1938
|
+
function isPrivateIp2(ip) {
|
|
1939
|
+
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);
|
|
1940
|
+
}
|
|
1791
1941
|
function defaultGetIp(req) {
|
|
1792
1942
|
const r = req;
|
|
1793
1943
|
const fwd = r["headers"];
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1944
|
+
function firstHdr(name) {
|
|
1945
|
+
const v = fwd?.[name];
|
|
1946
|
+
if (!v) return void 0;
|
|
1947
|
+
const s = (Array.isArray(v) ? v[0] : v.split(",")[0])?.trim();
|
|
1948
|
+
return s || void 0;
|
|
1798
1949
|
}
|
|
1799
|
-
const
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1950
|
+
const ip = firstHdr("cf-connecting-ip") ?? // Cloudflare
|
|
1951
|
+
firstHdr("true-client-ip") ?? // Cloudflare Enterprise / Akamai
|
|
1952
|
+
firstHdr("x-forwarded-for") ?? // Nginx, AWS ALB, GCP LB, most proxies
|
|
1953
|
+
firstHdr("x-real-ip") ?? // Nginx (single-IP alternative to XFF)
|
|
1954
|
+
firstHdr("fastly-client-ip") ?? // Fastly CDN
|
|
1955
|
+
firstHdr("x-client-ip") ?? // Generic reverse proxies
|
|
1956
|
+
firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
|
|
1957
|
+
r["socket"]?.remoteAddress ?? "0.0.0.0";
|
|
1958
|
+
return normalizeIp(ip);
|
|
1803
1959
|
}
|
|
1804
1960
|
function sendFakeResponse(res, fakeResponse) {
|
|
1805
1961
|
const allHeaders = {
|
|
@@ -1822,10 +1978,29 @@ function sendFakeResponse(res, fakeResponse) {
|
|
|
1822
1978
|
res["end"](fakeResponse.body);
|
|
1823
1979
|
}
|
|
1824
1980
|
}
|
|
1981
|
+
function isLoopbackIp(ip) {
|
|
1982
|
+
return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "::1";
|
|
1983
|
+
}
|
|
1825
1984
|
function createExpressMiddleware(client) {
|
|
1985
|
+
let ipCheckCount = 0;
|
|
1986
|
+
let ipLoopCount = 0;
|
|
1987
|
+
let ipWarnFired = false;
|
|
1988
|
+
let userIdCheckCount = 0;
|
|
1989
|
+
let userIdMissCount = 0;
|
|
1990
|
+
let userIdWarnFired = false;
|
|
1826
1991
|
return async function sentinelMiddleware(req, res, next) {
|
|
1827
1992
|
const startMs = Date.now();
|
|
1828
1993
|
const ip = client.config.getIp(req);
|
|
1994
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
1995
|
+
ipCheckCount++;
|
|
1996
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
1997
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
1998
|
+
ipWarnFired = true;
|
|
1999
|
+
console.warn(
|
|
2000
|
+
"[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"
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
1829
2004
|
if (client.isBlocked(ip)) {
|
|
1830
2005
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
1831
2006
|
const url_ = req["originalUrl"] ?? req["url"] ?? "/";
|
|
@@ -1882,12 +2057,12 @@ function createExpressMiddleware(client) {
|
|
|
1882
2057
|
const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
1883
2058
|
const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
|
|
1884
2059
|
const jti = pay["jti"] ?? "";
|
|
1885
|
-
const
|
|
1886
|
-
if (jti
|
|
2060
|
+
const cache = client["canaryTokenCache"];
|
|
2061
|
+
if (jti && cache.has(jti)) {
|
|
1887
2062
|
client.track("http.canary.triggered", {
|
|
1888
2063
|
ip,
|
|
1889
2064
|
userId,
|
|
1890
|
-
meta: { endpoint: url, method, token: jti.slice(
|
|
2065
|
+
meta: { endpoint: url, method, token: jti.slice(0, 16), source: "canary_jwt" }
|
|
1891
2066
|
});
|
|
1892
2067
|
}
|
|
1893
2068
|
}
|
|
@@ -1975,13 +2150,48 @@ function createExpressMiddleware(client) {
|
|
|
1975
2150
|
}
|
|
1976
2151
|
}
|
|
1977
2152
|
const onFinish = () => {
|
|
2153
|
+
const lateUserId = client.config.getUserId(req);
|
|
2154
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2155
|
+
userIdCheckCount++;
|
|
2156
|
+
if (!lateUserId) userIdMissCount++;
|
|
2157
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2158
|
+
userIdWarnFired = true;
|
|
2159
|
+
console.warn(
|
|
2160
|
+
"[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 })"
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
1978
2164
|
const status = res["statusCode"] ?? 0;
|
|
1979
2165
|
const latencyMs = Date.now() - startMs;
|
|
1980
2166
|
const getHeader = res["getHeader"];
|
|
1981
2167
|
const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
|
|
2168
|
+
const rawBody = req["body"];
|
|
2169
|
+
const piiFields = [];
|
|
2170
|
+
const piiPatterns = [];
|
|
2171
|
+
const piiCatSet = /* @__PURE__ */ new Set();
|
|
2172
|
+
if (rawBody != null && typeof rawBody === "object" && !Array.isArray(rawBody)) {
|
|
2173
|
+
const body = rawBody;
|
|
2174
|
+
for (const key of Object.keys(body)) {
|
|
2175
|
+
const cat = FIELD_CATEGORY_MAP.get(key.toLowerCase());
|
|
2176
|
+
if (cat) {
|
|
2177
|
+
piiFields.push(key.toLowerCase());
|
|
2178
|
+
piiCatSet.add(cat);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
for (const val of Object.values(body)) {
|
|
2182
|
+
if (typeof val !== "string" || val.length > 200) continue;
|
|
2183
|
+
for (const { name, category, re } of VALUE_PATTERNS) {
|
|
2184
|
+
if (!piiPatterns.includes(name) && re.test(val)) {
|
|
2185
|
+
piiPatterns.push(name);
|
|
2186
|
+
piiCatSet.add(category);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
const piiCategories = piiCatSet.size > 0 ? [...piiCatSet] : void 0;
|
|
1982
2192
|
client.track(EventName.REQUEST, {
|
|
1983
2193
|
ip,
|
|
1984
|
-
userId,
|
|
2194
|
+
userId: lateUserId,
|
|
1985
2195
|
meta: {
|
|
1986
2196
|
method,
|
|
1987
2197
|
endpoint: url,
|
|
@@ -1989,17 +2199,18 @@ function createExpressMiddleware(client) {
|
|
|
1989
2199
|
latencyMs,
|
|
1990
2200
|
userAgent: ua,
|
|
1991
2201
|
bytes,
|
|
2202
|
+
...piiFields.length > 0 ? { piiFields } : {},
|
|
2203
|
+
...piiPatterns.length > 0 ? { piiPatterns } : {},
|
|
2204
|
+
...piiCategories ? { piiCategories } : {},
|
|
1992
2205
|
_fp: fingerprint.score,
|
|
1993
2206
|
_fpSig: fingerprint.signals.join(","),
|
|
1994
2207
|
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|
|
1995
|
-
// HTTP/2 SETTINGS from client connection preface (when Node.js is TLS endpoint)
|
|
1996
2208
|
...h2settings ? {
|
|
1997
2209
|
_h2Window: h2settings.initialWindowSize,
|
|
1998
2210
|
_h2HdrTable: h2settings.headerTableSize,
|
|
1999
2211
|
_h2Score: h2settings.score,
|
|
2000
2212
|
_h2Signals: h2settings.signals.join(",")
|
|
2001
2213
|
} : {},
|
|
2002
|
-
// JA3/JA4 from upstream proxy (Nginx with ngx_ssl_fingerprint_module)
|
|
2003
2214
|
...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
|
|
2004
2215
|
...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
|
|
2005
2216
|
...agentInfo.isAgent ? {
|
|
@@ -2016,7 +2227,7 @@ function createExpressMiddleware(client) {
|
|
|
2016
2227
|
if (agentInfo.isAgent) {
|
|
2017
2228
|
client.track("http.agent_detected", {
|
|
2018
2229
|
ip,
|
|
2019
|
-
userId,
|
|
2230
|
+
userId: lateUserId,
|
|
2020
2231
|
meta: {
|
|
2021
2232
|
endpoint: url,
|
|
2022
2233
|
method,
|
|
@@ -2032,17 +2243,17 @@ function createExpressMiddleware(client) {
|
|
|
2032
2243
|
});
|
|
2033
2244
|
}
|
|
2034
2245
|
if (client.config.detect.rateAbuse && status === 429) {
|
|
2035
|
-
client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2246
|
+
client.track(EventName.RATE_LIMIT, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
2036
2247
|
}
|
|
2037
2248
|
if (client.config.detect.bruteForce && status === 401) {
|
|
2038
2249
|
if (/\/(login|signin|auth|token|session)/i.test(url)) {
|
|
2039
|
-
client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
|
|
2250
|
+
client.track(EventName.LOGIN_FAILED, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
|
|
2040
2251
|
}
|
|
2041
2252
|
}
|
|
2042
2253
|
if (client.config.detect.scanDetection && status === 404) {
|
|
2043
2254
|
const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
|
|
2044
2255
|
if (looksLikeScanner) {
|
|
2045
|
-
client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
|
|
2256
|
+
client.track(EventName.SCAN_DETECTED, { ip, userId: lateUserId, meta: { url, method, userAgent: ua } });
|
|
2046
2257
|
}
|
|
2047
2258
|
}
|
|
2048
2259
|
cleanup();
|
|
@@ -2055,9 +2266,25 @@ function createExpressMiddleware(client) {
|
|
|
2055
2266
|
};
|
|
2056
2267
|
}
|
|
2057
2268
|
function createFastifyPlugin(client) {
|
|
2269
|
+
let ipCheckCount = 0;
|
|
2270
|
+
let ipLoopCount = 0;
|
|
2271
|
+
let ipWarnFired = false;
|
|
2272
|
+
let userIdCheckCount = 0;
|
|
2273
|
+
let userIdMissCount = 0;
|
|
2274
|
+
let userIdWarnFired = false;
|
|
2058
2275
|
return async function sentinelFastifyPlugin(fastify) {
|
|
2059
2276
|
fastify.addHook("onRequest", (req, reply) => {
|
|
2060
2277
|
const ip = client.config.getIp(req);
|
|
2278
|
+
if (!ipWarnFired && ipCheckCount < 20) {
|
|
2279
|
+
ipCheckCount++;
|
|
2280
|
+
if (isLoopbackIp(ip)) ipLoopCount++;
|
|
2281
|
+
if (ipCheckCount === 20 && ipLoopCount >= 16) {
|
|
2282
|
+
ipWarnFired = true;
|
|
2283
|
+
console.warn(
|
|
2284
|
+
"[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"
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2061
2288
|
const url_ = req["url"] ?? "/";
|
|
2062
2289
|
const method_ = req["method"]?.toUpperCase() ?? "GET";
|
|
2063
2290
|
const hdrs_ = req["headers"];
|
|
@@ -2129,6 +2356,16 @@ function createFastifyPlugin(client) {
|
|
|
2129
2356
|
const method = req["method"]?.toUpperCase() ?? "GET";
|
|
2130
2357
|
const userId = client.config.getUserId(req);
|
|
2131
2358
|
const status = reply["statusCode"] ?? 0;
|
|
2359
|
+
if (!userIdWarnFired && userIdCheckCount < 20) {
|
|
2360
|
+
userIdCheckCount++;
|
|
2361
|
+
if (!userId) userIdMissCount++;
|
|
2362
|
+
if (userIdCheckCount === 20 && userIdMissCount >= 18) {
|
|
2363
|
+
userIdWarnFired = true;
|
|
2364
|
+
console.warn(
|
|
2365
|
+
"[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 })"
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2132
2369
|
const headers = req["headers"];
|
|
2133
2370
|
const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
|
|
2134
2371
|
const latencyMs = reply["elapsedTime"] ?? 0;
|
|
@@ -2212,6 +2449,11 @@ function createExpressMiddleware2(client) {
|
|
|
2212
2449
|
const ip = client.config.getIp(req);
|
|
2213
2450
|
const userId = client.config.getUserId(req);
|
|
2214
2451
|
const { method, originalUrl: url } = req;
|
|
2452
|
+
if (client.isBlocked(ip)) {
|
|
2453
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2454
|
+
res.status(403).json({ error: "Forbidden" });
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2215
2457
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
2216
2458
|
client.track(EventName.PATH_TRAVERSAL, {
|
|
2217
2459
|
ip,
|
|
@@ -2276,11 +2518,15 @@ function createExpressMiddleware2(client) {
|
|
|
2276
2518
|
// src/middleware/fastify.ts
|
|
2277
2519
|
function createFastifyPlugin2(client) {
|
|
2278
2520
|
return async function sentinelPlugin(fastify) {
|
|
2279
|
-
fastify.addHook("onRequest", async (req,
|
|
2521
|
+
fastify.addHook("onRequest", async (req, reply) => {
|
|
2280
2522
|
const ip = client.config.getIp(req);
|
|
2281
2523
|
const userId = client.config.getUserId(req);
|
|
2282
2524
|
const url = req.url;
|
|
2283
2525
|
const method = req.method.toUpperCase();
|
|
2526
|
+
if (client.isBlocked(ip)) {
|
|
2527
|
+
client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
|
|
2528
|
+
return reply.code(403).send({ error: "Forbidden" });
|
|
2529
|
+
}
|
|
2284
2530
|
if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
|
|
2285
2531
|
client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
|
|
2286
2532
|
}
|