@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/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;
@@ -620,7 +628,7 @@ function detectHoneypotType(path) {
620
628
  function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
621
629
  switch (type) {
622
630
  case "env_file":
623
- return makeEnvFile(canaryToken, callbackBase, orgId);
631
+ return makeEnvFile(canaryToken);
624
632
  case "aws_credentials":
625
633
  return makeAwsCredentials(canaryToken);
626
634
  case "git_config":
@@ -641,14 +649,16 @@ function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
641
649
  return makeGeneric(canaryToken);
642
650
  }
643
651
  }
644
- function makeEnvFile(canaryToken, callbackBase, orgId) {
652
+ function makeEnvFile(canaryToken, _callbackBase, _orgId) {
645
653
  const jwtSecret = genHex(32);
646
654
  const dbPassword = genAlphanumeric(20);
647
655
  const redisPassword = genAlphanumeric(16);
648
656
  const apiKey = genHex(32);
649
- const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
650
657
  const dnsDb = `db.${canaryToken}.srv.anomira.io`;
651
658
  const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
659
+ const dnsMonitoring = `hooks.monitoring.${canaryToken}.srv.anomira.io`;
660
+ const dnsAlerts = `hooks.alerts.${canaryToken}.srv.anomira.io`;
661
+ const dnsDeploy = `deploy.internal.${canaryToken}.srv.anomira.io`;
652
662
  const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
653
663
  const body = `# Production Environment Configuration
654
664
  # Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
@@ -679,9 +689,9 @@ AWS_REGION=eu-west-1
679
689
  AWS_S3_BUCKET=app-production-${genHex(4)}
680
690
 
681
691
  # \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
682
- MONITORING_WEBHOOK=${webhookUrl}
683
- ALERT_WEBHOOK=${webhookUrl}
684
- DEPLOY_HOOK=${callbackBase}/v1/canary/${orgId}/${canaryToken}
692
+ MONITORING_WEBHOOK=https://${dnsMonitoring}/v1/events/ingest
693
+ ALERT_WEBHOOK=https://${dnsAlerts}/services/${genHex(7).toUpperCase()}/notify
694
+ DEPLOY_HOOK=https://${dnsDeploy}/hooks/deploy/${genHex(6)}
685
695
 
686
696
  # \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
687
697
  INTERNAL_API_KEY=${apiKey}
@@ -1093,7 +1103,8 @@ function makeGeneric(canaryToken) {
1093
1103
  }
1094
1104
  function generateCanaryJwt(canaryToken, secret) {
1095
1105
  const now = Math.floor(Date.now() / 1e3);
1096
- const header = { alg: "HS256", typ: "JWT", kid: `canary-${canaryToken.slice(0, 12)}` };
1106
+ const kid = `${canaryToken.slice(0, 8)}-${canaryToken.slice(8, 12)}-4${canaryToken.slice(13, 16)}-${canaryToken.slice(16, 20)}-${canaryToken.slice(20, 32)}`;
1107
+ const header = { alg: "HS256", typ: "JWT", kid };
1097
1108
  const payload = {
1098
1109
  sub: "svc_internal_7482",
1099
1110
  name: "service-account",
@@ -1103,7 +1114,8 @@ function generateCanaryJwt(canaryToken, secret) {
1103
1114
  // issued 48h ago (realistic stale credential)
1104
1115
  exp: now - 86400,
1105
1116
  // expired 24h ago
1106
- jti: `canary-${canaryToken}`
1117
+ jti: canaryToken
1118
+ // raw hex — no "canary-" prefix to leak purpose
1107
1119
  };
1108
1120
  const h = Buffer.from(JSON.stringify(header)).toString("base64url");
1109
1121
  const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
@@ -1139,6 +1151,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
1139
1151
  var DEFAULT_BATCH_SIZE = 100;
1140
1152
  var DEFAULT_FLUSH_MS = 5e3;
1141
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
+ ];
1142
1256
  var AnomiraClient = class {
1143
1257
  constructor(config) {
1144
1258
  this.logBuffer = [];
@@ -1471,13 +1585,35 @@ var AnomiraClient = class {
1471
1585
  }
1472
1586
  }
1473
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
+ }
1474
1611
  /**
1475
1612
  * Track a custom security event.
1476
1613
  *
1477
1614
  * ```ts
1478
- * // Track a failed OTP attempt
1479
1615
  * sentinel.track(EventName.OTP_FAILED, {
1480
- * ip: req.ip,
1616
+ * ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
1481
1617
  * userId: req.body.phone,
1482
1618
  * meta: { endpoint: "/api/verify-otp", attempts: 3 },
1483
1619
  * });
@@ -1550,6 +1686,11 @@ var AnomiraClient = class {
1550
1686
  if (this.disabled) return;
1551
1687
  const ctx = requestContext.getStore();
1552
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
+ }
1553
1694
  const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
1554
1695
  const event = {
1555
1696
  name: eventName,
@@ -1765,6 +1906,12 @@ function defaultGetUserId(req) {
1765
1906
  );
1766
1907
  const jwtId = pickId(payload);
1767
1908
  if (jwtId) return jwtId;
1909
+ for (const val of Object.values(payload)) {
1910
+ if (val && typeof val === "object" && !Array.isArray(val)) {
1911
+ const nested = pickId(val);
1912
+ if (nested) return nested;
1913
+ }
1914
+ }
1768
1915
  }
1769
1916
  } catch {
1770
1917
  }
@@ -1784,18 +1931,27 @@ function normalizeIp(raw) {
1784
1931
  if (raw.startsWith("::ffff:")) return raw.slice(7);
1785
1932
  return raw;
1786
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
+ }
1787
1937
  function defaultGetIp(req) {
1788
1938
  const r = req;
1789
1939
  const fwd = r["headers"];
1790
- const xff = fwd?.["x-forwarded-for"];
1791
- if (xff) {
1792
- const first = Array.isArray(xff) ? xff[0] : xff.split(",")[0];
1793
- if (first) return normalizeIp(first.trim());
1940
+ function firstHdr(name) {
1941
+ const v = fwd?.[name];
1942
+ if (!v) return void 0;
1943
+ const s = (Array.isArray(v) ? v[0] : v.split(",")[0])?.trim();
1944
+ return s || void 0;
1794
1945
  }
1795
- const xri = fwd?.["x-real-ip"];
1796
- if (typeof xri === "string") return normalizeIp(xri.trim());
1797
- const socket = r["socket"];
1798
- return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
1946
+ const ip = firstHdr("cf-connecting-ip") ?? // Cloudflare
1947
+ firstHdr("true-client-ip") ?? // Cloudflare Enterprise / Akamai
1948
+ firstHdr("x-forwarded-for") ?? // Nginx, AWS ALB, GCP LB, most proxies
1949
+ firstHdr("x-real-ip") ?? // Nginx (single-IP alternative to XFF)
1950
+ firstHdr("fastly-client-ip") ?? // Fastly CDN
1951
+ firstHdr("x-client-ip") ?? // Generic reverse proxies
1952
+ firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
1953
+ r["socket"]?.remoteAddress ?? "0.0.0.0";
1954
+ return normalizeIp(ip);
1799
1955
  }
1800
1956
  function sendFakeResponse(res, fakeResponse) {
1801
1957
  const allHeaders = {
@@ -1818,10 +1974,29 @@ function sendFakeResponse(res, fakeResponse) {
1818
1974
  res["end"](fakeResponse.body);
1819
1975
  }
1820
1976
  }
1977
+ function isLoopbackIp(ip) {
1978
+ return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "::1";
1979
+ }
1821
1980
  function createExpressMiddleware(client) {
1981
+ let ipCheckCount = 0;
1982
+ let ipLoopCount = 0;
1983
+ let ipWarnFired = false;
1984
+ let userIdCheckCount = 0;
1985
+ let userIdMissCount = 0;
1986
+ let userIdWarnFired = false;
1822
1987
  return async function sentinelMiddleware(req, res, next) {
1823
1988
  const startMs = Date.now();
1824
1989
  const ip = client.config.getIp(req);
1990
+ if (!ipWarnFired && ipCheckCount < 20) {
1991
+ ipCheckCount++;
1992
+ if (isLoopbackIp(ip)) ipLoopCount++;
1993
+ if (ipCheckCount === 20 && ipLoopCount >= 16) {
1994
+ ipWarnFired = true;
1995
+ console.warn(
1996
+ "[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"
1997
+ );
1998
+ }
1999
+ }
1825
2000
  if (client.isBlocked(ip)) {
1826
2001
  const method_ = req["method"]?.toUpperCase() ?? "GET";
1827
2002
  const url_ = req["originalUrl"] ?? req["url"] ?? "/";
@@ -1878,12 +2053,12 @@ function createExpressMiddleware(client) {
1878
2053
  const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
1879
2054
  const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
1880
2055
  const jti = pay["jti"] ?? "";
1881
- const kid = hdr["kid"] ?? "";
1882
- if (jti.startsWith("canary-") && client["canaryTokenCache"].has(jti.slice(7)) || kid.startsWith("canary-") && client["canaryTokenCache"].has(kid.slice(7))) {
2056
+ const cache = client["canaryTokenCache"];
2057
+ if (jti && cache.has(jti)) {
1883
2058
  client.track("http.canary.triggered", {
1884
2059
  ip,
1885
2060
  userId,
1886
- meta: { endpoint: url, method, token: jti.slice(7, 23), source: "canary_jwt" }
2061
+ meta: { endpoint: url, method, token: jti.slice(0, 16), source: "canary_jwt" }
1887
2062
  });
1888
2063
  }
1889
2064
  }
@@ -1971,13 +2146,48 @@ function createExpressMiddleware(client) {
1971
2146
  }
1972
2147
  }
1973
2148
  const onFinish = () => {
2149
+ const lateUserId = client.config.getUserId(req);
2150
+ if (!userIdWarnFired && userIdCheckCount < 20) {
2151
+ userIdCheckCount++;
2152
+ if (!lateUserId) userIdMissCount++;
2153
+ if (userIdCheckCount === 20 && userIdMissCount >= 18) {
2154
+ userIdWarnFired = true;
2155
+ console.warn(
2156
+ "[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 })"
2157
+ );
2158
+ }
2159
+ }
1974
2160
  const status = res["statusCode"] ?? 0;
1975
2161
  const latencyMs = Date.now() - startMs;
1976
2162
  const getHeader = res["getHeader"];
1977
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;
1978
2188
  client.track(EventName.REQUEST, {
1979
2189
  ip,
1980
- userId,
2190
+ userId: lateUserId,
1981
2191
  meta: {
1982
2192
  method,
1983
2193
  endpoint: url,
@@ -1985,17 +2195,18 @@ function createExpressMiddleware(client) {
1985
2195
  latencyMs,
1986
2196
  userAgent: ua,
1987
2197
  bytes,
2198
+ ...piiFields.length > 0 ? { piiFields } : {},
2199
+ ...piiPatterns.length > 0 ? { piiPatterns } : {},
2200
+ ...piiCategories ? { piiCategories } : {},
1988
2201
  _fp: fingerprint.score,
1989
2202
  _fpSig: fingerprint.signals.join(","),
1990
2203
  ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
1991
- // HTTP/2 SETTINGS from client connection preface (when Node.js is TLS endpoint)
1992
2204
  ...h2settings ? {
1993
2205
  _h2Window: h2settings.initialWindowSize,
1994
2206
  _h2HdrTable: h2settings.headerTableSize,
1995
2207
  _h2Score: h2settings.score,
1996
2208
  _h2Signals: h2settings.signals.join(",")
1997
2209
  } : {},
1998
- // JA3/JA4 from upstream proxy (Nginx with ngx_ssl_fingerprint_module)
1999
2210
  ...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
2000
2211
  ...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
2001
2212
  ...agentInfo.isAgent ? {
@@ -2012,7 +2223,7 @@ function createExpressMiddleware(client) {
2012
2223
  if (agentInfo.isAgent) {
2013
2224
  client.track("http.agent_detected", {
2014
2225
  ip,
2015
- userId,
2226
+ userId: lateUserId,
2016
2227
  meta: {
2017
2228
  endpoint: url,
2018
2229
  method,
@@ -2028,17 +2239,17 @@ function createExpressMiddleware(client) {
2028
2239
  });
2029
2240
  }
2030
2241
  if (client.config.detect.rateAbuse && status === 429) {
2031
- client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
2242
+ client.track(EventName.RATE_LIMIT, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
2032
2243
  }
2033
2244
  if (client.config.detect.bruteForce && status === 401) {
2034
2245
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2035
- client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
2246
+ client.track(EventName.LOGIN_FAILED, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
2036
2247
  }
2037
2248
  }
2038
2249
  if (client.config.detect.scanDetection && status === 404) {
2039
2250
  const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
2040
2251
  if (looksLikeScanner) {
2041
- client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
2252
+ client.track(EventName.SCAN_DETECTED, { ip, userId: lateUserId, meta: { url, method, userAgent: ua } });
2042
2253
  }
2043
2254
  }
2044
2255
  cleanup();
@@ -2051,9 +2262,25 @@ function createExpressMiddleware(client) {
2051
2262
  };
2052
2263
  }
2053
2264
  function createFastifyPlugin(client) {
2265
+ let ipCheckCount = 0;
2266
+ let ipLoopCount = 0;
2267
+ let ipWarnFired = false;
2268
+ let userIdCheckCount = 0;
2269
+ let userIdMissCount = 0;
2270
+ let userIdWarnFired = false;
2054
2271
  return async function sentinelFastifyPlugin(fastify) {
2055
2272
  fastify.addHook("onRequest", (req, reply) => {
2056
2273
  const ip = client.config.getIp(req);
2274
+ if (!ipWarnFired && ipCheckCount < 20) {
2275
+ ipCheckCount++;
2276
+ if (isLoopbackIp(ip)) ipLoopCount++;
2277
+ if (ipCheckCount === 20 && ipLoopCount >= 16) {
2278
+ ipWarnFired = true;
2279
+ console.warn(
2280
+ "[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"
2281
+ );
2282
+ }
2283
+ }
2057
2284
  const url_ = req["url"] ?? "/";
2058
2285
  const method_ = req["method"]?.toUpperCase() ?? "GET";
2059
2286
  const hdrs_ = req["headers"];
@@ -2125,6 +2352,16 @@ function createFastifyPlugin(client) {
2125
2352
  const method = req["method"]?.toUpperCase() ?? "GET";
2126
2353
  const userId = client.config.getUserId(req);
2127
2354
  const status = reply["statusCode"] ?? 0;
2355
+ if (!userIdWarnFired && userIdCheckCount < 20) {
2356
+ userIdCheckCount++;
2357
+ if (!userId) userIdMissCount++;
2358
+ if (userIdCheckCount === 20 && userIdMissCount >= 18) {
2359
+ userIdWarnFired = true;
2360
+ console.warn(
2361
+ "[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 })"
2362
+ );
2363
+ }
2364
+ }
2128
2365
  const headers = req["headers"];
2129
2366
  const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
2130
2367
  const latencyMs = reply["elapsedTime"] ?? 0;
@@ -2208,6 +2445,11 @@ function createExpressMiddleware2(client) {
2208
2445
  const ip = client.config.getIp(req);
2209
2446
  const userId = client.config.getUserId(req);
2210
2447
  const { method, originalUrl: url } = req;
2448
+ if (client.isBlocked(ip)) {
2449
+ client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
2450
+ res.status(403).json({ error: "Forbidden" });
2451
+ return;
2452
+ }
2211
2453
  if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
2212
2454
  client.track(EventName.PATH_TRAVERSAL, {
2213
2455
  ip,
@@ -2272,11 +2514,15 @@ function createExpressMiddleware2(client) {
2272
2514
  // src/middleware/fastify.ts
2273
2515
  function createFastifyPlugin2(client) {
2274
2516
  return async function sentinelPlugin(fastify) {
2275
- fastify.addHook("onRequest", async (req, _reply) => {
2517
+ fastify.addHook("onRequest", async (req, reply) => {
2276
2518
  const ip = client.config.getIp(req);
2277
2519
  const userId = client.config.getUserId(req);
2278
2520
  const url = req.url;
2279
2521
  const method = req.method.toUpperCase();
2522
+ if (client.isBlocked(ip)) {
2523
+ client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
2524
+ return reply.code(403).send({ error: "Forbidden" });
2525
+ }
2280
2526
  if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
2281
2527
  client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
2282
2528
  }