@anomira/node-sdk 0.1.0

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.cjs ADDED
@@ -0,0 +1,973 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var async_hooks = require('async_hooks');
6
+
7
+ // src/client.ts
8
+
9
+ // src/buffer.ts
10
+ var EventBuffer = class {
11
+ constructor(opts) {
12
+ this.queue = [];
13
+ this.timer = null;
14
+ this.flushing = false;
15
+ this.opts = opts;
16
+ this.startTimer();
17
+ this.registerShutdownHook();
18
+ }
19
+ // ─── Public API ────────────────────────────────────────────────────────────
20
+ push(event) {
21
+ this.queue.push(event);
22
+ this.log(`[buffer] +1 event \u2192 ${this.queue.length} queued (${event.name})`);
23
+ if (this.queue.length >= this.opts.maxBatchSize) {
24
+ this.log("[buffer] batch size reached \u2014 flushing immediately");
25
+ void this.flush();
26
+ }
27
+ }
28
+ async flush() {
29
+ if (this.flushing || this.queue.length === 0) return;
30
+ this.flushing = true;
31
+ const batch = this.queue.splice(0, this.opts.maxBatchSize);
32
+ this.log(`[buffer] flushing ${batch.length} events`);
33
+ try {
34
+ await this.sendWithRetry(batch);
35
+ } catch (err) {
36
+ this.warn(`[buffer] dropped ${batch.length} events after max retries:`, err);
37
+ } finally {
38
+ this.flushing = false;
39
+ }
40
+ }
41
+ /** Call on graceful shutdown to flush remaining events synchronously. */
42
+ async shutdown() {
43
+ if (this.timer) {
44
+ clearInterval(this.timer);
45
+ this.timer = null;
46
+ }
47
+ await this.flush();
48
+ this.log("[buffer] shutdown complete");
49
+ }
50
+ // ─── Internals ─────────────────────────────────────────────────────────────
51
+ startTimer() {
52
+ this.timer = setInterval(() => {
53
+ void this.flush();
54
+ }, this.opts.flushIntervalMs);
55
+ if (this.timer.unref) this.timer.unref();
56
+ }
57
+ registerShutdownHook() {
58
+ const current = process.getMaxListeners();
59
+ process.setMaxListeners(current + 3);
60
+ const handler = () => {
61
+ void this.shutdown();
62
+ };
63
+ process.once("SIGTERM", handler);
64
+ process.once("SIGINT", handler);
65
+ process.once("beforeExit", handler);
66
+ }
67
+ async sendWithRetry(events, attempt = 1) {
68
+ const { appId, apiKey, ingestUrl, maxRetries } = this.opts;
69
+ const payload = { appId, events };
70
+ try {
71
+ const res = await fetch(ingestUrl, {
72
+ method: "POST",
73
+ redirect: "manual",
74
+ headers: {
75
+ Authorization: `Bearer ${apiKey}`,
76
+ "Content-Type": "application/json",
77
+ "User-Agent": `@sentinelapi/node-sdk/0.1.0`
78
+ },
79
+ body: JSON.stringify(payload),
80
+ signal: AbortSignal.timeout(8e3)
81
+ });
82
+ if (res.ok) {
83
+ this.log(`[buffer] \u2705 sent ${events.length} events (attempt ${attempt})`);
84
+ return;
85
+ }
86
+ if (res.status >= 300 && res.status < 400) {
87
+ this.warn(`[buffer] \u274C Wrong ingest URL \u2014 got redirect to ${res.headers.get("location")}. Check SENTINEL_INGEST_URL.`);
88
+ return;
89
+ }
90
+ if (res.status === 401) {
91
+ this.warn("[buffer] \u274C Invalid API key \u2014 check your SENTINEL_API_KEY environment variable");
92
+ return;
93
+ }
94
+ if (res.status === 403) {
95
+ this.warn("[buffer] \u274C App not found \u2014 check your SENTINEL_APP_ID environment variable");
96
+ return;
97
+ }
98
+ if (res.status >= 400 && res.status < 500) {
99
+ this.warn(`[buffer] \u26A0\uFE0F ingest rejected ${res.status} \u2014 dropping batch`);
100
+ return;
101
+ }
102
+ throw new Error(`Ingest HTTP ${res.status}`);
103
+ } catch (err) {
104
+ if (attempt >= maxRetries) throw err;
105
+ const delayMs = Math.min(1e3 * 2 ** (attempt - 1), 16e3);
106
+ this.log(`[buffer] retry ${attempt}/${maxRetries} in ${delayMs}ms`);
107
+ await sleep(delayMs);
108
+ return this.sendWithRetry(events, attempt + 1);
109
+ }
110
+ }
111
+ log(...args) {
112
+ if (this.opts.debug) console.log("[SentinelAPI]", ...args);
113
+ }
114
+ warn(...args) {
115
+ console.warn("[SentinelAPI]", ...args);
116
+ }
117
+ };
118
+ function sleep(ms) {
119
+ return new Promise((r) => setTimeout(r, ms));
120
+ }
121
+
122
+ // src/geo-velocity.ts
123
+ var lastSeen = /* @__PURE__ */ new Map();
124
+ var geoCache = /* @__PURE__ */ new Map();
125
+ var MAX_SPEED_KMH = 900;
126
+ var PRIVATE_RANGES = [
127
+ /^10\./,
128
+ /^172\.(1[6-9]|2\d|3[01])\./,
129
+ /^192\.168\./,
130
+ /^127\./,
131
+ /^::1$/,
132
+ /^0\.0\.0\.0$/,
133
+ /^fc00:/i,
134
+ /^fe80:/i
135
+ ];
136
+ function isPrivateIp(ip) {
137
+ return PRIVATE_RANGES.some((r) => r.test(ip));
138
+ }
139
+ async function lookupGeo(ip, lookupUrl) {
140
+ if (!ip || isPrivateIp(ip)) return null;
141
+ const cached = geoCache.get(ip);
142
+ if (cached && cached.expiresAt > Date.now()) return cached.point;
143
+ const url = lookupUrl ? `${lookupUrl}?ip=${encodeURIComponent(ip)}` : null;
144
+ if (!url) return null;
145
+ try {
146
+ const res = await fetch(url, { signal: AbortSignal.timeout(2e3) });
147
+ if (!res.ok) return null;
148
+ const data = await res.json();
149
+ if (data.lat == null || data.lng == null) return null;
150
+ const point = {
151
+ ip,
152
+ lat: data.lat,
153
+ lng: data.lng,
154
+ country: data.country,
155
+ city: data.city
156
+ };
157
+ geoCache.set(ip, { point, expiresAt: Date.now() + 36e5 });
158
+ return point;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ function haversineKm(lat1, lng1, lat2, lng2) {
164
+ const R = 6371;
165
+ const dLat = toRad(lat2 - lat1);
166
+ const dLng = toRad(lng2 - lng1);
167
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
168
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
169
+ }
170
+ function toRad(deg) {
171
+ return deg * Math.PI / 180;
172
+ }
173
+ async function checkGeoVelocity(userId, ip, tsMs, lookupUrl) {
174
+ const geo = await lookupGeo(ip, lookupUrl);
175
+ if (!geo) return null;
176
+ const currentPoint = { ...geo, tsMs };
177
+ const prev = lastSeen.get(userId);
178
+ lastSeen.set(userId, currentPoint);
179
+ if (lastSeen.size > 1e4) {
180
+ const cutoff = Date.now() - 864e5;
181
+ for (const [key, val] of lastSeen) {
182
+ if (val.tsMs < cutoff) lastSeen.delete(key);
183
+ }
184
+ }
185
+ if (!prev) return null;
186
+ if (prev.ip === ip) return null;
187
+ const distanceKm = haversineKm(prev.lat, prev.lng, currentPoint.lat, currentPoint.lng);
188
+ const hours = Math.max((tsMs - prev.tsMs) / 36e5, 1e-3);
189
+ const speedKmH = distanceKm / hours;
190
+ if (speedKmH < MAX_SPEED_KMH) return null;
191
+ return {
192
+ isImpossible: true,
193
+ distanceKm: Math.round(distanceKm),
194
+ speedKmH: Math.round(speedKmH),
195
+ from: prev,
196
+ to: currentPoint
197
+ };
198
+ }
199
+
200
+ // src/sensitive.ts
201
+ var PATTERNS = [
202
+ {
203
+ // "password: abc123", "pwd=secret", or standalone "password123"
204
+ type: "password",
205
+ label: "Password / Secret",
206
+ regex: /\bpassword\w*|\b(?:passwd|pwd|pass|secret|credentials?)\s*[:=]\s*\S+/i
207
+ },
208
+ {
209
+ // Nigerian BVN / NIN — exactly 11 digits, not a phone number (phones start with 0)
210
+ type: "bvn",
211
+ label: "BVN / NIN",
212
+ regex: /\b[1-9]\d{10}\b/
213
+ },
214
+ {
215
+ // JWT — three base64url segments separated by dots
216
+ type: "jwt",
217
+ label: "JWT Token",
218
+ regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/
219
+ },
220
+ {
221
+ // API keys: sk_live_*, pk_live_*, or key/token/bearer label with long value
222
+ type: "api_key",
223
+ label: "API Key / Token",
224
+ regex: /\b(?:sk|pk|rk)[-_](?:live|test|prod|secret)[-_][A-Za-z0-9]{16,}|\b(?:token|bearer|access_token|api_key)\s*[:=]\s*[A-Za-z0-9_./-]{20,}/i
225
+ },
226
+ {
227
+ // Card PANs: Visa (4), Mastercard (51-55), Amex (34/37), Discover (6011/65)
228
+ type: "card_pan",
229
+ label: "Card PAN",
230
+ regex: /\b(?:4[0-9]{15}|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/
231
+ },
232
+ {
233
+ // Nigerian phone numbers: 080x, 081x, 070x, 090x, 091x — or with +234 prefix
234
+ type: "ng_phone",
235
+ label: "Phone Number",
236
+ regex: /\b(?:\+?234|0)(?:7[0-9]|8[0-1]|9[0-1])\d{8}\b/
237
+ },
238
+ {
239
+ // Email address appearing alongside other content — possible credential combo
240
+ // e.g. "john@test.com password123"
241
+ type: "email_credential",
242
+ label: "Email in Log",
243
+ regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/
244
+ }
245
+ ];
246
+ function scanForLeaks(message) {
247
+ const found = [];
248
+ for (const p of PATTERNS) {
249
+ p.regex.lastIndex = 0;
250
+ if (p.regex.test(message)) {
251
+ found.push({ type: p.type, label: p.label });
252
+ }
253
+ }
254
+ return found;
255
+ }
256
+
257
+ // src/types.ts
258
+ var EventName = {
259
+ // Auth
260
+ LOGIN_SUCCESS: "auth.login.success",
261
+ LOGIN_FAILED: "auth.login.failed",
262
+ LOGOUT: "auth.logout",
263
+ OTP_FAILED: "auth.otp.failed",
264
+ OTP_SUCCESS: "auth.otp.success",
265
+ BVN_LOOKUP: "auth.bvn.lookup",
266
+ NIN_LOOKUP: "auth.nin.lookup",
267
+ // NIN enumeration detection
268
+ GEO_VELOCITY: "auth.login.geo_velocity",
269
+ CREDENTIAL_STUFF: "auth.credential.stuffing",
270
+ SIM_SWAP: "auth.sim_swap.suspected",
271
+ // SIM swap fraud signal
272
+ PHONE_AUTH: "auth.phone.verified",
273
+ // Phone-based auth (OTP/2FA via phone)
274
+ // HTTP layer (auto-detected by middleware)
275
+ REQUEST: "http.request",
276
+ // every request — feeds the Events dashboard
277
+ RATE_LIMIT: "http.ratelimit.exceeded",
278
+ XSS_DETECTED: "http.xss.detected",
279
+ PATH_TRAVERSAL: "http.path.traversal",
280
+ SCAN_DETECTED: "http.scan.detected",
281
+ IDOR_ATTEMPT: "user.idor.attempt",
282
+ SQL_ERROR: "db.sql.error",
283
+ // Firewall (emitted when a custom request filtering rule fires)
284
+ FIREWALL_BLOCK: "http.firewall.block",
285
+ FIREWALL_FLAG: "http.firewall.flag"
286
+ };
287
+
288
+ // src/client.ts
289
+ var requestContext = new async_hooks.AsyncLocalStorage();
290
+ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
291
+ var DEFAULT_BATCH_SIZE = 100;
292
+ var DEFAULT_FLUSH_MS = 5e3;
293
+ var DEFAULT_MAX_RETRIES = 3;
294
+ var SentinelClient = class {
295
+ constructor(config) {
296
+ this.logBuffer = [];
297
+ this.logFlushTimer = null;
298
+ this.blocklistTimer = null;
299
+ this.firewallTimer = null;
300
+ /** In-process cache of blocked IPs — refreshed every 60 s from the ingest server. */
301
+ this.blockedIpCache = /* @__PURE__ */ new Set();
302
+ /** In-process cache of firewall rules with pre-compiled regex — refreshed every 60 s. */
303
+ this.compiledRules = [];
304
+ // Saved originals — used by SDK internals so patched console doesn't recurse
305
+ this._origLog = console.log.bind(console);
306
+ this._origWarn = console.warn.bind(console);
307
+ this._origError = console.error.bind(console);
308
+ if (!config.apiKey) throw new Error("[SentinelAPI] apiKey is required");
309
+ if (!config.appId) throw new Error("[SentinelAPI] appId is required");
310
+ this.config = {
311
+ apiKey: config.apiKey,
312
+ appId: config.appId,
313
+ ingestUrl: config.ingestUrl ?? DEFAULT_INGEST_URL,
314
+ geoLookupUrl: config.geoLookupUrl ?? "",
315
+ maxBatchSize: config.maxBatchSize ?? DEFAULT_BATCH_SIZE,
316
+ flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
317
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
318
+ debug: config.debug ?? false,
319
+ captureConsole: config.captureConsole ?? false,
320
+ service: config.service ?? "app",
321
+ getUserId: config.getUserId ?? defaultGetUserId,
322
+ getIp: config.getIp ?? defaultGetIp,
323
+ detect: {
324
+ bruteForce: config.detect?.bruteForce ?? true,
325
+ rateAbuse: config.detect?.rateAbuse ?? true,
326
+ pathTraversal: config.detect?.pathTraversal ?? true,
327
+ xss: config.detect?.xss ?? true,
328
+ scanDetection: config.detect?.scanDetection ?? true,
329
+ geoVelocity: config.detect?.geoVelocity ?? true
330
+ }
331
+ };
332
+ this.buffer = new EventBuffer({
333
+ appId: this.config.appId,
334
+ apiKey: this.config.apiKey,
335
+ ingestUrl: this.config.ingestUrl,
336
+ maxBatchSize: this.config.maxBatchSize,
337
+ flushIntervalMs: this.config.flushIntervalMs,
338
+ maxRetries: this.config.maxRetries,
339
+ debug: this.config.debug
340
+ });
341
+ void this.#validateCredentials();
342
+ void this.#refreshBlocklist();
343
+ this.blocklistTimer = setInterval(() => {
344
+ void this.#refreshBlocklist();
345
+ }, 6e4);
346
+ if (this.blocklistTimer.unref) this.blocklistTimer.unref();
347
+ void this.#refreshFirewallRules();
348
+ this.firewallTimer = setInterval(() => {
349
+ void this.#refreshFirewallRules();
350
+ }, 6e4);
351
+ if (this.firewallTimer.unref) this.firewallTimer.unref();
352
+ this.logFlushTimer = setInterval(() => {
353
+ void this.#flushLogs();
354
+ }, 1e4);
355
+ if (this.logFlushTimer.unref) this.logFlushTimer.unref();
356
+ if (this.config.captureConsole) this.#interceptConsole();
357
+ }
358
+ #interceptConsole() {
359
+ const map = [
360
+ ["debug", "debug"],
361
+ ["log", "info"],
362
+ ["info", "info"],
363
+ ["warn", "warn"],
364
+ ["error", "error"]
365
+ ];
366
+ for (const [method, level] of map) {
367
+ const original = console[method].bind(console);
368
+ console[method] = (...args) => {
369
+ original(...args);
370
+ const first = args[0];
371
+ if (typeof first === "string" && first.startsWith("[SentinelAPI]")) return;
372
+ const message = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
373
+ const ctx = requestContext.getStore();
374
+ this.log(level, message, {
375
+ service: this.config.service,
376
+ ...ctx ? { endpoint: ctx.endpoint, method: ctx.method, ip: ctx.ip } : {}
377
+ });
378
+ };
379
+ }
380
+ }
381
+ async #refreshBlocklist() {
382
+ const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/blocked-ips/sync");
383
+ try {
384
+ const res = await fetch(syncUrl, {
385
+ headers: {
386
+ Authorization: `Bearer ${this.config.apiKey}`,
387
+ "User-Agent": `@anomira/node-sdk/0.1.0`
388
+ },
389
+ signal: AbortSignal.timeout(5e3)
390
+ });
391
+ if (!res.ok) return;
392
+ const data = await res.json();
393
+ this.blockedIpCache = new Set(data.ips ?? []);
394
+ if (this.config.debug && this.blockedIpCache.size > 0) {
395
+ this._origLog(`[SentinelAPI] blocklist refreshed \u2014 ${this.blockedIpCache.size} blocked IPs`);
396
+ }
397
+ } catch {
398
+ }
399
+ }
400
+ async #refreshFirewallRules() {
401
+ const syncUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/firewall-rules/sync");
402
+ try {
403
+ const res = await fetch(syncUrl, {
404
+ headers: {
405
+ Authorization: `Bearer ${this.config.apiKey}`,
406
+ "User-Agent": `@anomira/node-sdk/0.1.0`
407
+ },
408
+ signal: AbortSignal.timeout(5e3)
409
+ });
410
+ if (!res.ok) return;
411
+ const data = await res.json();
412
+ this.compiledRules = (data.rules ?? []).map((rule) => ({
413
+ rule,
414
+ re: rule.operator === "regex" ? (() => {
415
+ try {
416
+ return new RegExp(rule.value, "i");
417
+ } catch {
418
+ return void 0;
419
+ }
420
+ })() : void 0
421
+ }));
422
+ if (this.config.debug && this.compiledRules.length > 0) {
423
+ this._origLog(`[SentinelAPI] firewall rules refreshed \u2014 ${this.compiledRules.length} active rules`);
424
+ }
425
+ } catch {
426
+ }
427
+ }
428
+ /** Evaluate all cached firewall rules against the current request.
429
+ * Returns the first matching rule, or null if none match. */
430
+ #matchFirewallRule(req) {
431
+ for (const { rule, re } of this.compiledRules) {
432
+ let target;
433
+ switch (rule.field) {
434
+ case "url":
435
+ target = req.url;
436
+ break;
437
+ case "body":
438
+ target = typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? "");
439
+ break;
440
+ case "header":
441
+ target = req.headers[(rule.headerName ?? "").toLowerCase()] ?? "";
442
+ break;
443
+ case "user_agent":
444
+ target = req.headers["user-agent"] ?? "";
445
+ break;
446
+ case "ip":
447
+ target = req.ip;
448
+ break;
449
+ default:
450
+ continue;
451
+ }
452
+ const matched = rule.operator === "regex" ? re?.test(target) ?? false : rule.operator === "contains" ? target.includes(rule.value) : rule.operator === "equals" ? target === rule.value : rule.operator === "starts_with" ? target.startsWith(rule.value) : rule.operator === "ends_with" ? target.endsWith(rule.value) : false;
453
+ if (matched) return { rule };
454
+ }
455
+ return null;
456
+ }
457
+ async #flushLogs() {
458
+ if (this.logBuffer.length === 0) return;
459
+ const batch = this.logBuffer.splice(0, 500);
460
+ const logsUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/logs");
461
+ try {
462
+ const res = await fetch(logsUrl, {
463
+ method: "POST",
464
+ redirect: "manual",
465
+ headers: {
466
+ Authorization: `Bearer ${this.config.apiKey}`,
467
+ "Content-Type": "application/json",
468
+ "User-Agent": `@anomira/node-sdk/0.1.0`
469
+ },
470
+ body: JSON.stringify({ appId: this.config.appId, logs: batch }),
471
+ signal: AbortSignal.timeout(8e3)
472
+ });
473
+ if (this.config.debug) {
474
+ this._origLog(`[SentinelAPI] [logs] \u2705 sent ${batch.length} log entries (${res.status})`);
475
+ }
476
+ } catch {
477
+ this.logBuffer.unshift(...batch);
478
+ }
479
+ }
480
+ async #validateCredentials() {
481
+ const pingUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/ping") + `?appId=${encodeURIComponent(this.config.appId)}`;
482
+ try {
483
+ const res = await fetch(pingUrl, {
484
+ method: "GET",
485
+ redirect: "manual",
486
+ headers: {
487
+ Authorization: `Bearer ${this.config.apiKey}`,
488
+ "User-Agent": `@anomira/node-sdk/0.1.0`
489
+ },
490
+ signal: AbortSignal.timeout(5e3)
491
+ });
492
+ if (res.status >= 300 && res.status < 400) {
493
+ this._origWarn(`[SentinelAPI] \u274C Wrong ingest URL \u2014 got redirect to ${res.headers.get("location")}. Check SENTINEL_INGEST_URL.`);
494
+ return;
495
+ }
496
+ if (res.ok) {
497
+ this._origLog(`[SentinelAPI] \u2705 Connected (appId: ${this.config.appId.slice(0, 8)}\u2026)`);
498
+ return;
499
+ }
500
+ if (res.status === 401) {
501
+ this._origWarn("[SentinelAPI] \u274C Invalid API key \u2014 check your SENTINEL_API_KEY");
502
+ return;
503
+ }
504
+ if (res.status === 403) {
505
+ this._origWarn("[SentinelAPI] \u274C App not found or appId mismatch \u2014 check your SENTINEL_APP_ID");
506
+ return;
507
+ }
508
+ this._origWarn(`[SentinelAPI] \u26A0\uFE0F Ingest returned HTTP ${res.status} \u2014 check your configuration`);
509
+ } catch {
510
+ this._origWarn("[SentinelAPI] \u26A0\uFE0F Could not reach ingest endpoint \u2014 check SENTINEL_INGEST_URL (current: " + this.config.ingestUrl + ")");
511
+ }
512
+ }
513
+ // ─── Public API ────────────────────────────────────────────────────────────
514
+ /**
515
+ * Track a custom security event.
516
+ *
517
+ * ```ts
518
+ * // Track a failed OTP attempt
519
+ * sentinel.track(EventName.OTP_FAILED, {
520
+ * ip: req.ip,
521
+ * userId: req.body.phone,
522
+ * meta: { endpoint: "/api/verify-otp", attempts: 3 },
523
+ * });
524
+ * ```
525
+ */
526
+ /** Returns true if the IP is in the current blocked list. Synchronous — no network call. */
527
+ isBlocked(ip) {
528
+ return this.blockedIpCache.has(ip);
529
+ }
530
+ /** Evaluate firewall rules against a request. Returns the matched rule or null. Synchronous. */
531
+ matchFirewallRule(req) {
532
+ return this.#matchFirewallRule(req);
533
+ }
534
+ track(eventName, data) {
535
+ const event = {
536
+ name: eventName,
537
+ ts: Date.now(),
538
+ ip: data.ip,
539
+ userId: data.userId,
540
+ meta: data.meta
541
+ };
542
+ this.buffer.push(event);
543
+ }
544
+ /**
545
+ * Track a successful login AND run geo-velocity check.
546
+ * If impossible travel is detected, automatically fires an additional
547
+ * `auth.login.geo_velocity` event with full context.
548
+ *
549
+ * ```ts
550
+ * await sentinel.trackLogin({
551
+ * ip: req.ip,
552
+ * userId: user.id,
553
+ * });
554
+ * ```
555
+ */
556
+ async trackLogin(data) {
557
+ const tsMs = Date.now();
558
+ this.track(EventName.LOGIN_SUCCESS, { ...data, meta: { ...data.meta } });
559
+ if (!this.config.detect.geoVelocity) return;
560
+ try {
561
+ const result = await checkGeoVelocity(data.userId, data.ip, tsMs, this.config.geoLookupUrl || void 0);
562
+ if (!result) return;
563
+ this.track(EventName.GEO_VELOCITY, {
564
+ ip: data.ip,
565
+ userId: data.userId,
566
+ meta: {
567
+ distanceKm: result.distanceKm,
568
+ speedKmH: result.speedKmH,
569
+ fromIp: result.from.ip,
570
+ fromCity: result.from.city,
571
+ fromCountry: result.from.country,
572
+ toCity: result.to.city,
573
+ toCountry: result.to.country,
574
+ minutesDiff: Math.round((result.to.tsMs - result.from.tsMs) / 6e4),
575
+ ...data.meta
576
+ }
577
+ });
578
+ } catch {
579
+ }
580
+ }
581
+ /**
582
+ * Track phone-based authentication (OTP via SMS, WhatsApp, or call).
583
+ * The ingest service uses this to detect SIM swap patterns:
584
+ * if the same userId authenticates via phone but then appears on a new
585
+ * device/IP shortly after, it's flagged as a suspected SIM swap.
586
+ *
587
+ * ```ts
588
+ * await sentinel.trackPhoneAuth({
589
+ * ip: req.ip,
590
+ * userId: user.id,
591
+ * phone: user.phoneNumber,
592
+ * });
593
+ * ```
594
+ */
595
+ trackPhoneAuth(data) {
596
+ this.track(EventName.PHONE_AUTH, {
597
+ ip: data.ip,
598
+ userId: data.userId,
599
+ meta: { phone: data.phone, ...data.meta }
600
+ });
601
+ }
602
+ /**
603
+ * Send a structured log entry to the SentinelAPI Logs dashboard.
604
+ *
605
+ * ```ts
606
+ * sentinel.log("info", "User registered", { userId: user.id });
607
+ * sentinel.log("warn", "Slow DB query detected", { queryMs: 1240 });
608
+ * sentinel.log("error", "Payment failed", { reason: err.message });
609
+ * ```
610
+ */
611
+ log(level, message, meta) {
612
+ const { service, ...rest } = meta ?? {};
613
+ const leaks = scanForLeaks(message);
614
+ if (leaks.length > 0) {
615
+ rest["sensitiveLeaks"] = leaks.map((l) => l.type);
616
+ if (this.config.debug) {
617
+ this._origWarn(
618
+ `[SentinelAPI] \u26A0\uFE0F Sensitive data in log (${leaks.map((l) => l.label).join(", ")}): "${message.slice(0, 60)}\u2026"`
619
+ );
620
+ }
621
+ }
622
+ this.logBuffer.push({ level, service: service ?? this.config.service, message, meta: rest, ts: Date.now() });
623
+ if (this.config.debug && leaks.length === 0) {
624
+ this._origLog(`[SentinelAPI] log:${level} ${message}`);
625
+ }
626
+ if (this.logBuffer.length >= 50) void this.#flushLogs();
627
+ }
628
+ /**
629
+ * Flush all pending events immediately.
630
+ * Useful before a graceful shutdown outside of the process lifecycle hooks.
631
+ */
632
+ async flush() {
633
+ await Promise.all([this.buffer.flush(), this.#flushLogs()]);
634
+ }
635
+ /**
636
+ * Express middleware — auto-instruments all routes.
637
+ *
638
+ * ```ts
639
+ * app.use(sentinel.express());
640
+ * ```
641
+ */
642
+ express() {
643
+ return createExpressMiddleware(this);
644
+ }
645
+ /**
646
+ * Fastify plugin — auto-instruments all routes.
647
+ *
648
+ * ```ts
649
+ * await app.register(sentinel.fastify());
650
+ * ```
651
+ */
652
+ fastify() {
653
+ return createFastifyPlugin(this);
654
+ }
655
+ };
656
+ function defaultGetUserId(req) {
657
+ const r = req;
658
+ const user = r["user"];
659
+ if (!user) return void 0;
660
+ return user["id"] ?? user["userId"] ?? user["sub"] ?? user["_id"];
661
+ }
662
+ function normalizeIp(raw) {
663
+ if (raw === "::1") return "127.0.0.1";
664
+ if (raw === "::ffff:127.0.0.1") return "127.0.0.1";
665
+ if (raw.startsWith("::ffff:")) return raw.slice(7);
666
+ return raw;
667
+ }
668
+ function defaultGetIp(req) {
669
+ const r = req;
670
+ const fwd = r["headers"];
671
+ const xff = fwd?.["x-forwarded-for"];
672
+ if (xff) {
673
+ const first = Array.isArray(xff) ? xff[0] : xff.split(",")[0];
674
+ if (first) return normalizeIp(first.trim());
675
+ }
676
+ const xri = fwd?.["x-real-ip"];
677
+ if (typeof xri === "string") return normalizeIp(xri.trim());
678
+ const socket = r["socket"];
679
+ return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
680
+ }
681
+ function createExpressMiddleware(client) {
682
+ return async function sentinelMiddleware(req, res, next) {
683
+ const startMs = Date.now();
684
+ const ip = client.config.getIp(req);
685
+ if (client.isBlocked(ip)) {
686
+ const res_ = res;
687
+ if (typeof res_["status"] === "function") {
688
+ res_["status"](403);
689
+ } else {
690
+ res_["statusCode"] = 403;
691
+ }
692
+ if (typeof res_["end"] === "function") res_["end"]('{"error":"Forbidden"}');
693
+ return;
694
+ }
695
+ const userId = client.config.getUserId(req);
696
+ const method = req["method"]?.toUpperCase() ?? "GET";
697
+ const url = req["originalUrl"] ?? req["url"] ?? "/";
698
+ const headers = req["headers"];
699
+ const ua = headers?.["user-agent"] ?? "";
700
+ const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headers ?? {}, ip });
701
+ if (fwMatch) {
702
+ client.track("http.firewall." + fwMatch.rule.action, {
703
+ ip,
704
+ userId,
705
+ meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
706
+ });
707
+ if (fwMatch.rule.action === "block") {
708
+ const res_ = res;
709
+ if (typeof res_["status"] === "function") res_["status"](403);
710
+ else res_["statusCode"] = 403;
711
+ if (typeof res_["end"] === "function") res_["end"]('{"error":"Blocked by firewall rule"}');
712
+ return;
713
+ }
714
+ }
715
+ if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
716
+ client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
717
+ }
718
+ if (client.config.detect.xss && ["POST", "PUT", "PATCH"].includes(method)) {
719
+ const body = JSON.stringify(req["body"]);
720
+ if (/<script|javascript:|on\w+=/i.test(body)) {
721
+ client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
722
+ }
723
+ }
724
+ const onFinish = () => {
725
+ const status = res["statusCode"] ?? 0;
726
+ const latencyMs = Date.now() - startMs;
727
+ const getHeader = res["getHeader"];
728
+ const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
729
+ client.track(EventName.REQUEST, {
730
+ ip,
731
+ userId,
732
+ meta: { method, endpoint: url, status, latencyMs, userAgent: ua, bytes }
733
+ });
734
+ if (client.config.detect.rateAbuse && status === 429) {
735
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
736
+ }
737
+ if (client.config.detect.bruteForce && status === 401) {
738
+ if (/\/(login|signin|auth|token|session)/i.test(url)) {
739
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
740
+ }
741
+ }
742
+ if (client.config.detect.scanDetection && status === 404) {
743
+ const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
744
+ if (looksLikeScanner) {
745
+ client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
746
+ }
747
+ }
748
+ cleanup();
749
+ };
750
+ const cleanup = () => {
751
+ res.off?.("finish", onFinish);
752
+ };
753
+ res.on?.("finish", onFinish);
754
+ requestContext.run({ endpoint: url, method, ip }, next);
755
+ };
756
+ }
757
+ function createFastifyPlugin(client) {
758
+ return async function sentinelFastifyPlugin(fastify) {
759
+ fastify.addHook("onRequest", (req, reply) => {
760
+ const ip = client.config.getIp(req);
761
+ if (client.isBlocked(ip)) {
762
+ const rep = reply;
763
+ if (typeof rep["code"] === "function") {
764
+ const chained = rep["code"](403);
765
+ if (chained && typeof chained["send"] === "function") {
766
+ chained["send"]({ error: "Forbidden" });
767
+ }
768
+ }
769
+ return;
770
+ }
771
+ const url = req["url"] ?? "/";
772
+ const method = req["method"]?.toUpperCase() ?? "GET";
773
+ const userId = client.config.getUserId(req);
774
+ if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
775
+ client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
776
+ }
777
+ });
778
+ fastify.addHook("preHandler", (req, reply) => {
779
+ const ip = client.config.getIp(req);
780
+ const url = req["url"] ?? "/";
781
+ const method = req["method"]?.toUpperCase() ?? "GET";
782
+ const userId = client.config.getUserId(req);
783
+ const headers = req["headers"];
784
+ const fwMatch = client.matchFirewallRule({ url, body: req["body"], headers: headers ?? {}, ip });
785
+ if (fwMatch) {
786
+ client.track("http.firewall." + fwMatch.rule.action, {
787
+ ip,
788
+ userId,
789
+ meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
790
+ });
791
+ if (fwMatch.rule.action === "block") {
792
+ const rep = reply;
793
+ if (typeof rep["code"] === "function") {
794
+ const chained = rep["code"](403);
795
+ if (chained && typeof chained["send"] === "function") {
796
+ chained["send"]({ error: "Blocked by firewall rule" });
797
+ }
798
+ }
799
+ return;
800
+ }
801
+ }
802
+ if (client.config.detect.xss && ["POST", "PUT", "PATCH"].includes(method)) {
803
+ const body = JSON.stringify(req["body"]);
804
+ if (/<script|javascript:|on\w+=/i.test(body)) {
805
+ client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
806
+ }
807
+ }
808
+ });
809
+ fastify.addHook("onResponse", (req, reply) => {
810
+ const ip = client.config.getIp(req);
811
+ const url = req["url"] ?? "/";
812
+ const method = req["method"]?.toUpperCase() ?? "GET";
813
+ const userId = client.config.getUserId(req);
814
+ const status = reply["statusCode"] ?? 0;
815
+ const headers = req["headers"];
816
+ const ua = headers?.["user-agent"] ?? "";
817
+ const latencyMs = reply["elapsedTime"] ?? 0;
818
+ client.track(EventName.REQUEST, {
819
+ ip,
820
+ userId,
821
+ meta: { method, endpoint: url, status, latencyMs: Math.round(latencyMs), userAgent: ua, bytes: 0 }
822
+ });
823
+ if (client.config.detect.rateAbuse && status === 429) {
824
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
825
+ }
826
+ if (client.config.detect.bruteForce && status === 401 && /\/(login|signin|auth|token)/i.test(url)) {
827
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
828
+ }
829
+ if (client.config.detect.scanDetection && status === 404) {
830
+ if (!ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua)) {
831
+ client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
832
+ }
833
+ }
834
+ if (client.config.detect.bruteForce && status === 200 && /\/(login|signin|auth|token)/i.test(url)) {
835
+ if (userId) {
836
+ void client.trackLogin({ ip, userId, meta: { url, method } });
837
+ }
838
+ }
839
+ });
840
+ };
841
+ }
842
+
843
+ // src/middleware/express.ts
844
+ function createExpressMiddleware2(client) {
845
+ return function sentinelMiddleware(req, res, next) {
846
+ const ip = client.config.getIp(req);
847
+ const userId = client.config.getUserId(req);
848
+ const { method, originalUrl: url } = req;
849
+ if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
850
+ client.track(EventName.PATH_TRAVERSAL, {
851
+ ip,
852
+ userId,
853
+ meta: { url, method }
854
+ });
855
+ }
856
+ if (client.config.detect.xss && ["POST", "PUT", "PATCH"].includes(method)) {
857
+ try {
858
+ const body = JSON.stringify(req.body);
859
+ if (/<script|javascript:|on\w+=/i.test(body)) {
860
+ client.track(EventName.XSS_DETECTED, {
861
+ ip,
862
+ userId,
863
+ meta: { url, method }
864
+ });
865
+ }
866
+ } catch {
867
+ }
868
+ }
869
+ const onFinish = () => {
870
+ res.off("finish", onFinish);
871
+ const { statusCode } = res;
872
+ if (client.config.detect.rateAbuse && statusCode === 429) {
873
+ client.track(EventName.RATE_LIMIT, {
874
+ ip,
875
+ userId,
876
+ meta: { url, method, statusCode }
877
+ });
878
+ }
879
+ if (client.config.detect.bruteForce && statusCode === 401) {
880
+ if (/\/(login|signin|auth|token|session)/i.test(url)) {
881
+ client.track(EventName.LOGIN_FAILED, {
882
+ ip,
883
+ userId,
884
+ meta: { url, method, statusCode }
885
+ });
886
+ }
887
+ }
888
+ if (client.config.detect.scanDetection && statusCode === 404) {
889
+ const ua = req.headers["user-agent"] ?? "";
890
+ if (!ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua)) {
891
+ client.track(EventName.SCAN_DETECTED, {
892
+ ip,
893
+ userId,
894
+ meta: { url, method, userAgent: ua }
895
+ });
896
+ }
897
+ }
898
+ if (client.config.detect.geoVelocity && statusCode === 200 && /\/(login|signin|auth|token)/i.test(url) && method === "POST") {
899
+ const resolvedUserId = client.config.getUserId(req);
900
+ if (resolvedUserId) {
901
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
902
+ }
903
+ }
904
+ };
905
+ res.on("finish", onFinish);
906
+ next();
907
+ };
908
+ }
909
+
910
+ // src/middleware/fastify.ts
911
+ function createFastifyPlugin2(client) {
912
+ return async function sentinelPlugin(fastify) {
913
+ fastify.addHook("onRequest", async (req, _reply) => {
914
+ const ip = client.config.getIp(req);
915
+ const userId = client.config.getUserId(req);
916
+ const url = req.url;
917
+ const method = req.method.toUpperCase();
918
+ if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
919
+ client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
920
+ }
921
+ });
922
+ fastify.addHook("preHandler", async (req, _reply) => {
923
+ const ip = client.config.getIp(req);
924
+ const userId = client.config.getUserId(req);
925
+ const url = req.url;
926
+ const method = req.method.toUpperCase();
927
+ if (client.config.detect.xss && ["POST", "PUT", "PATCH"].includes(method)) {
928
+ try {
929
+ const body = JSON.stringify(req.body);
930
+ if (/<script|javascript:|on\w+=/i.test(body)) {
931
+ client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
932
+ }
933
+ } catch {
934
+ }
935
+ }
936
+ });
937
+ fastify.addHook("onResponse", async (req, reply) => {
938
+ const ip = client.config.getIp(req);
939
+ const userId = client.config.getUserId(req);
940
+ const url = req.url;
941
+ const method = req.method.toUpperCase();
942
+ const statusCode = reply.statusCode;
943
+ if (client.config.detect.rateAbuse && statusCode === 429) {
944
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode } });
945
+ }
946
+ if (client.config.detect.bruteForce && statusCode === 401) {
947
+ if (/\/(login|signin|auth|token|session)/i.test(url)) {
948
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode } });
949
+ }
950
+ }
951
+ if (client.config.detect.scanDetection && statusCode === 404) {
952
+ const ua = req.headers["user-agent"] ?? "";
953
+ if (!ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua)) {
954
+ client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
955
+ }
956
+ }
957
+ if (client.config.detect.geoVelocity && statusCode === 200 && method === "POST" && /\/(login|signin|auth|token)/i.test(url)) {
958
+ const resolvedUserId = client.config.getUserId(req);
959
+ if (resolvedUserId) {
960
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
961
+ }
962
+ }
963
+ });
964
+ };
965
+ }
966
+
967
+ exports.EventName = EventName;
968
+ exports.SentinelAPI = SentinelClient;
969
+ exports.createExpressMiddleware = createExpressMiddleware2;
970
+ exports.createFastifyPlugin = createFastifyPlugin2;
971
+ exports.default = SentinelClient;
972
+ //# sourceMappingURL=index.cjs.map
973
+ //# sourceMappingURL=index.cjs.map