@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 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.ip,
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: req.ip,
182
- userId: user.id,
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.ip,
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({ ip: req.ip, userId: user.id, phone: user.phone });
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.ip,
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.ip,
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(req.ip)) {
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: req.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 `req.ip`?**
383
- Express types sometimes don't include `ip` directly. Use `(req as express.Request).ip ?? req.socket.remoteAddress ?? "0.0.0.0"`.
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
- isDefiniteBot = true;
541
- knownClient = "axios";
542
- score = 100;
543
- signals.push("known_accept:axios");
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 } : {},