@anomira/node-sdk 0.2.3 → 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 +173 -6
- 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 +173 -6
- 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;
|
|
@@ -1147,6 +1155,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
|
|
|
1147
1155
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1148
1156
|
var DEFAULT_FLUSH_MS = 5e3;
|
|
1149
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
|
+
];
|
|
1150
1260
|
var AnomiraClient = class {
|
|
1151
1261
|
constructor(config) {
|
|
1152
1262
|
this.logBuffer = [];
|
|
@@ -1479,13 +1589,35 @@ var AnomiraClient = class {
|
|
|
1479
1589
|
}
|
|
1480
1590
|
}
|
|
1481
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
|
+
}
|
|
1482
1615
|
/**
|
|
1483
1616
|
* Track a custom security event.
|
|
1484
1617
|
*
|
|
1485
1618
|
* ```ts
|
|
1486
|
-
* // Track a failed OTP attempt
|
|
1487
1619
|
* sentinel.track(EventName.OTP_FAILED, {
|
|
1488
|
-
* ip: req.ip
|
|
1620
|
+
* ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
|
|
1489
1621
|
* userId: req.body.phone,
|
|
1490
1622
|
* meta: { endpoint: "/api/verify-otp", attempts: 3 },
|
|
1491
1623
|
* });
|
|
@@ -1558,6 +1690,11 @@ var AnomiraClient = class {
|
|
|
1558
1690
|
if (this.disabled) return;
|
|
1559
1691
|
const ctx = requestContext.getStore();
|
|
1560
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
|
+
}
|
|
1561
1698
|
const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
|
|
1562
1699
|
const event = {
|
|
1563
1700
|
name: eventName,
|
|
@@ -1798,6 +1935,9 @@ function normalizeIp(raw) {
|
|
|
1798
1935
|
if (raw.startsWith("::ffff:")) return raw.slice(7);
|
|
1799
1936
|
return raw;
|
|
1800
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
|
+
}
|
|
1801
1941
|
function defaultGetIp(req) {
|
|
1802
1942
|
const r = req;
|
|
1803
1943
|
const fwd = r["headers"];
|
|
@@ -2025,6 +2165,30 @@ function createExpressMiddleware(client) {
|
|
|
2025
2165
|
const latencyMs = Date.now() - startMs;
|
|
2026
2166
|
const getHeader = res["getHeader"];
|
|
2027
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;
|
|
2028
2192
|
client.track(EventName.REQUEST, {
|
|
2029
2193
|
ip,
|
|
2030
2194
|
userId: lateUserId,
|
|
@@ -2035,6 +2199,9 @@ function createExpressMiddleware(client) {
|
|
|
2035
2199
|
latencyMs,
|
|
2036
2200
|
userAgent: ua,
|
|
2037
2201
|
bytes,
|
|
2202
|
+
...piiFields.length > 0 ? { piiFields } : {},
|
|
2203
|
+
...piiPatterns.length > 0 ? { piiPatterns } : {},
|
|
2204
|
+
...piiCategories ? { piiCategories } : {},
|
|
2038
2205
|
_fp: fingerprint.score,
|
|
2039
2206
|
_fpSig: fingerprint.signals.join(","),
|
|
2040
2207
|
...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
|