@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 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;
@@ -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, callbackBase, orgId);
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, callbackBase, orgId) {
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=${webhookUrl}
687
- ALERT_WEBHOOK=${webhookUrl}
688
- DEPLOY_HOOK=${callbackBase}/v1/canary/${orgId}/${canaryToken}
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 header = { alg: "HS256", typ: "JWT", kid: `canary-${canaryToken.slice(0, 12)}` };
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: `canary-${canaryToken}`
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
- const xff = fwd?.["x-forwarded-for"];
1795
- if (xff) {
1796
- const first = Array.isArray(xff) ? xff[0] : xff.split(",")[0];
1797
- if (first) return normalizeIp(first.trim());
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 xri = fwd?.["x-real-ip"];
1800
- if (typeof xri === "string") return normalizeIp(xri.trim());
1801
- const socket = r["socket"];
1802
- return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
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 kid = hdr["kid"] ?? "";
1886
- if (jti.startsWith("canary-") && client["canaryTokenCache"].has(jti.slice(7)) || kid.startsWith("canary-") && client["canaryTokenCache"].has(kid.slice(7))) {
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(7, 23), source: "canary_jwt" }
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, _reply) => {
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
  }