@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/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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 } : {},
|