@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/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
- isDefiniteBot = true;
537
- knownClient = "axios";
538
- score = 100;
539
- signals.push("known_accept:axios");
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;
@@ -1143,6 +1151,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
1143
1151
  var DEFAULT_BATCH_SIZE = 100;
1144
1152
  var DEFAULT_FLUSH_MS = 5e3;
1145
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
+ ];
1146
1256
  var AnomiraClient = class {
1147
1257
  constructor(config) {
1148
1258
  this.logBuffer = [];
@@ -1475,13 +1585,35 @@ var AnomiraClient = class {
1475
1585
  }
1476
1586
  }
1477
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
+ }
1478
1611
  /**
1479
1612
  * Track a custom security event.
1480
1613
  *
1481
1614
  * ```ts
1482
- * // Track a failed OTP attempt
1483
1615
  * sentinel.track(EventName.OTP_FAILED, {
1484
- * ip: req.ip,
1616
+ * ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
1485
1617
  * userId: req.body.phone,
1486
1618
  * meta: { endpoint: "/api/verify-otp", attempts: 3 },
1487
1619
  * });
@@ -1554,6 +1686,11 @@ var AnomiraClient = class {
1554
1686
  if (this.disabled) return;
1555
1687
  const ctx = requestContext.getStore();
1556
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
+ }
1557
1694
  const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
1558
1695
  const event = {
1559
1696
  name: eventName,
@@ -1794,6 +1931,9 @@ function normalizeIp(raw) {
1794
1931
  if (raw.startsWith("::ffff:")) return raw.slice(7);
1795
1932
  return raw;
1796
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
+ }
1797
1937
  function defaultGetIp(req) {
1798
1938
  const r = req;
1799
1939
  const fwd = r["headers"];
@@ -2021,6 +2161,30 @@ function createExpressMiddleware(client) {
2021
2161
  const latencyMs = Date.now() - startMs;
2022
2162
  const getHeader = res["getHeader"];
2023
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;
2024
2188
  client.track(EventName.REQUEST, {
2025
2189
  ip,
2026
2190
  userId: lateUserId,
@@ -2031,6 +2195,9 @@ function createExpressMiddleware(client) {
2031
2195
  latencyMs,
2032
2196
  userAgent: ua,
2033
2197
  bytes,
2198
+ ...piiFields.length > 0 ? { piiFields } : {},
2199
+ ...piiPatterns.length > 0 ? { piiPatterns } : {},
2200
+ ...piiCategories ? { piiCategories } : {},
2034
2201
  _fp: fingerprint.score,
2035
2202
  _fpSig: fingerprint.signals.join(","),
2036
2203
  ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},