@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 +973 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +315 -0
- package/dist/index.d.ts +315 -0
- package/dist/index.js +965 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
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
|