@anomira/node-sdk 0.1.9 → 0.2.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/README.md CHANGED
@@ -2,184 +2,296 @@
2
2
 
3
3
  Drop-in API security monitoring for Node.js. Detect brute force, credential stuffing, account takeover, data scraping, path traversal, XSS, geo-velocity attacks, and more — in real time.
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/@anomira/node-sdk)](https://www.npmjs.com/package/@anomira/node-sdk)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
5
8
  ## Install
6
9
 
7
10
  ```bash
8
11
  npm install @anomira/node-sdk
12
+ # or
13
+ yarn add @anomira/node-sdk
14
+ # or
15
+ pnpm add @anomira/node-sdk
9
16
  ```
10
17
 
11
- ## Quick Start
18
+ **Requirements:** Node.js 18+, ESM or CommonJS.
12
19
 
13
- ```js
14
- import { Anomira } from "@anomira/node-sdk";
20
+ ---
15
21
 
16
- const sentinel = new Anomira({
17
- apiKey: process.env.SENTINEL_API_KEY,
18
- appId: process.env.SENTINEL_APP_ID,
19
- ingestUrl: process.env.SENTINEL_INGEST_URL,
20
- debug: true,
21
- });
22
+ ## Quick Start (5 minutes)
23
+
24
+ ### 1. Set your environment variables
25
+
26
+ Copy these into your `.env` file. Get the values from your [Anomira dashboard](https://app.anomira.io) under **Apps → Setup**.
27
+
28
+ ```env
29
+ ANOMIRA_API_KEY=ak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
30
+ ANOMIRA_APP_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
22
31
  ```
23
32
 
33
+ > **Never hardcode these values.** Use environment variables or a secrets manager. Run `npx @anomira/node-sdk scan .` to catch any leaks before you commit.
34
+
35
+ ### 2. Add the middleware — pick your framework
36
+
37
+ ---
38
+
24
39
  ## Express
25
40
 
26
- ```js
41
+ The middleware goes **after** `express.json()` and **before** your routes so it can read request bodies.
42
+
43
+ ```ts
44
+ // app.ts (TypeScript)
27
45
  import express from "express";
28
46
  import { Anomira } from "@anomira/node-sdk";
29
47
 
30
48
  const app = express();
31
49
 
32
- const sentinel = new Anomira({
33
- apiKey: process.env.SENTINEL_API_KEY,
34
- appId: process.env.SENTINEL_APP_ID,
35
- ingestUrl: process.env.SENTINEL_INGEST_URL,
36
- debug: true,
37
- captureConsole: true, // forwards console.log/warn/error to Logs dashboard
38
- service: "my-api",
39
- detect: {
40
- bruteForce: true,
41
- rateAbuse: true,
42
- pathTraversal: true,
43
- xss: true,
44
- scanDetection: true,
45
- geoVelocity: true,
46
- },
50
+ const anomira = new Anomira({
51
+ apiKey: process.env.ANOMIRA_API_KEY!,
52
+ appId: process.env.ANOMIRA_APP_ID!,
53
+ service: "my-api", // appears in the Logs dashboard
54
+ captureConsole: true, // forwards console.log/warn/error to Logs
47
55
  });
48
56
 
49
57
  app.use(express.json());
50
- app.use(sentinel.express()); // auto-instruments all routes
58
+ app.use(anomira.express()); // ← single line — instruments all routes
59
+
60
+ // Your routes
61
+ app.post("/api/auth/login", (req, res) => { /* ... */ });
62
+ app.get("/api/users/:id", (req, res) => { /* ... */ });
63
+
64
+ // Always flush before shutdown
65
+ process.on("SIGTERM", async () => {
66
+ await anomira.flush();
67
+ process.exit(0);
68
+ });
69
+
70
+ app.listen(3000, () => console.log("API running on :3000"));
71
+ ```
72
+
73
+ **CommonJS:**
74
+
75
+ ```js
76
+ // app.js
77
+ const express = require("express");
78
+ const { Anomira } = require("@anomira/node-sdk");
79
+
80
+ const app = express();
81
+ const anomira = new Anomira({
82
+ apiKey: process.env.ANOMIRA_API_KEY,
83
+ appId: process.env.ANOMIRA_APP_ID,
84
+ });
51
85
 
86
+ app.use(express.json());
87
+ app.use(anomira.express());
52
88
  app.listen(3000);
53
89
  ```
54
90
 
91
+ ---
92
+
55
93
  ## Fastify
56
94
 
57
- ```js
95
+ ```ts
96
+ // server.ts
58
97
  import Fastify from "fastify";
59
98
  import { Anomira } from "@anomira/node-sdk";
60
99
 
61
- const app = Fastify();
100
+ const app = Fastify({ logger: true });
62
101
 
63
- const sentinel = new Anomira({
64
- apiKey: process.env.SENTINEL_API_KEY,
65
- appId: process.env.SENTINEL_APP_ID,
66
- ingestUrl: process.env.SENTINEL_INGEST_URL,
102
+ const anomira = new Anomira({
103
+ apiKey: process.env.ANOMIRA_API_KEY!,
104
+ appId: process.env.ANOMIRA_APP_ID!,
105
+ service: "my-api",
67
106
  });
68
107
 
69
- await app.register(sentinel.fastify());
108
+ // Register BEFORE your route plugins
109
+ await app.register(anomira.fastify());
110
+
111
+ // Your routes
112
+ app.post("/api/auth/login", async (req, reply) => { /* ... */ });
113
+ app.get("/api/users/:id", async (req, reply) => { /* ... */ });
70
114
 
71
- app.listen({ port: 3000 });
115
+ process.on("SIGTERM", async () => {
116
+ await anomira.flush();
117
+ await app.close();
118
+ });
119
+
120
+ await app.listen({ port: 3000, host: "0.0.0.0" });
72
121
  ```
73
122
 
74
- ## Manual Event Tracking
123
+ ---
75
124
 
76
- ```js
77
- // Track a failed OTP attempt
78
- sentinel.track("auth.otp.failed", {
125
+ ## What the middleware captures automatically
126
+
127
+ Once registered, the middleware records **every request** with zero additional code:
128
+
129
+ | Signal | Description |
130
+ |---|---|
131
+ | HTTP method, path, status | Full request log in **API Events** |
132
+ | Source IP + geolocation | Country, city, lat/lng |
133
+ | Latency | `latencyMs` per request |
134
+ | Brute force | Repeated failures on the same endpoint |
135
+ | Rate abuse | Unusually high request rate from one IP |
136
+ | Path traversal | `../`, `%2e%2e/`, null bytes in paths |
137
+ | XSS | Script tags and injection payloads in bodies |
138
+ | Scanner/bot probing | Systematic enumeration of endpoints |
139
+ | Geo-velocity | Impossible logins from two countries within minutes |
140
+
141
+ ---
142
+
143
+ ## Manual event tracking
144
+
145
+ Use these when the middleware can't infer the event on its own — e.g., application-level failures.
146
+
147
+ ```ts
148
+ // Credential stuffing / failed login attempt
149
+ await anomira.trackLogin({
150
+ ip: req.ip,
151
+ userId: req.body.email, // email or user ID
152
+ success: false, // false = failed attempt
153
+ });
154
+
155
+ // Successful login (enables geo-velocity tracking)
156
+ await anomira.trackLogin({
157
+ ip: req.ip,
158
+ userId: user.id,
159
+ success: true,
160
+ });
161
+
162
+ // Failed OTP — otp_flood detection
163
+ anomira.track("auth.otp.failed", {
79
164
  ip: req.ip,
80
165
  userId: req.body.phone,
81
166
  meta: { endpoint: "/api/verify-otp" },
82
167
  });
83
168
 
84
- // Track a login and run geo-velocity check automatically
85
- await sentinel.trackLogin({ ip: req.ip, userId: user.id });
169
+ // SIM swap detection call after any phone-based auth
170
+ anomira.trackPhoneAuth({ ip: req.ip, userId: user.id, phone: user.phone });
86
171
 
87
- // Track phone-based auth (SIM swap detection)
88
- sentinel.trackPhoneAuth({ ip: req.ip, userId: user.id, phone: user.phone });
172
+ // Account takeover signal e.g., password changed from new IP
173
+ anomira.track("auth.account.takeover", {
174
+ ip: req.ip,
175
+ userId: user.id,
176
+ meta: { reason: "password_changed_new_ip" },
177
+ });
178
+
179
+ // Webhook replay — call when you detect a replayed webhook signature
180
+ anomira.track("webhook.replay.detected", {
181
+ ip: req.ip,
182
+ meta: { webhookId: req.headers["x-webhook-id"] },
183
+ });
89
184
  ```
90
185
 
91
- ## Structured Logging
186
+ ---
92
187
 
93
- ```js
94
- sentinel.log("info", "User registered", { userId: user.id });
95
- sentinel.log("warn", "Slow DB query", { queryMs: 1240 });
96
- sentinel.log("error", "Payment failed", { reason: err.message });
188
+ ## Structured logging
189
+
190
+ Replace `console.log` calls with `anomira.log` to push structured logs to the **Logs dashboard** with level, service name, and metadata.
191
+
192
+ ```ts
193
+ anomira.log("info", "User registered", { userId: user.id, plan: "starter" });
194
+ anomira.log("warn", "Slow DB query", { queryMs: 1240, table: "transactions" });
195
+ anomira.log("error", "Payment failed", { reason: err.message, amount: 500_00 });
196
+ anomira.log("debug", "Cache miss", { key: cacheKey });
97
197
  ```
98
198
 
99
- ## Blocklist & Firewall
199
+ Or set `captureConsole: true` in the constructor to automatically forward all `console.*` calls — no code changes needed.
100
200
 
101
- The SDK syncs your blocklist and firewall rules from the dashboard every 60 seconds. Check them in your own middleware:
201
+ ---
102
202
 
103
- ```js
104
- if (sentinel.isBlocked(req.ip)) {
105
- return res.status(403).json({ error: "Forbidden" });
106
- }
203
+ ## Blocklist & firewall check
107
204
 
108
- const match = sentinel.matchFirewallRule({
109
- url: req.url,
110
- body: req.body,
111
- headers: req.headers,
112
- ip: req.ip,
205
+ The SDK syncs your dashboard's blocked IPs and firewall rules every 60 seconds. Use these checks in a gateway middleware before your routes:
206
+
207
+ ```ts
208
+ app.use((req, res, next) => {
209
+ // Check if IP is manually blocked in the dashboard
210
+ if (anomira.isBlocked(req.ip)) {
211
+ return res.status(403).json({ error: "Forbidden" });
212
+ }
213
+
214
+ // Check firewall rules (pattern-based: path, method, header, body)
215
+ const match = anomira.matchFirewallRule({
216
+ url: req.originalUrl,
217
+ method: req.method,
218
+ body: req.body,
219
+ headers: req.headers,
220
+ ip: req.ip,
221
+ });
222
+
223
+ if (match) {
224
+ if (match.rule.action === "block") {
225
+ return res.status(403).json({ error: "Request blocked", rule: match.rule.name });
226
+ }
227
+ // action === "flag" — let it through but the dashboard will flag it
228
+ }
229
+
230
+ next();
113
231
  });
114
- if (match?.rule.action === "block") {
115
- return res.status(403).json({ error: "Blocked by firewall rule" });
116
- }
117
232
  ```
118
233
 
119
- ## Shadow Endpoint Detection
234
+ ---
120
235
 
121
- Register your known API routes on startup so Anomira can flag any undeclared endpoint that appears in live traffic. Endpoints that receive requests but were never declared show up in the **API Surface Map** as shadow endpoints.
236
+ ## Shadow endpoint detection
122
237
 
123
- ```js
124
- // Call once after your routes are registered
125
- await sentinel.declareEndpoints([
126
- { method: "POST", path: "/api/auth/login", auth: false },
127
- { method: "POST", path: "/api/auth/register", auth: false },
128
- { method: "GET", path: "/api/users/:id", auth: true },
129
- { method: "GET", path: "/api/orders", auth: true },
130
- { method: "POST", path: "/api/orders", auth: true },
131
- { method: "GET", path: "/api/health", auth: false },
238
+ Register your known API routes once on startup. Anomira will flag any traffic to undeclared endpoints in the **API Surface** view — a leading indicator of probing and abuse.
239
+
240
+ ```ts
241
+ // Call this once, after all your routes are defined
242
+ await anomira.declareEndpoints([
243
+ { method: "POST", path: "/api/auth/login", auth: false },
244
+ { method: "POST", path: "/api/auth/register", auth: false },
245
+ { method: "POST", path: "/api/auth/forgot", auth: false },
246
+ { method: "GET", path: "/api/users/:id", auth: true },
247
+ { method: "PUT", path: "/api/users/:id", auth: true },
248
+ { method: "GET", path: "/api/orders", auth: true },
249
+ { method: "POST", path: "/api/orders", auth: true },
250
+ { method: "GET", path: "/api/health", auth: false },
132
251
  ]);
133
252
  ```
134
253
 
135
- Express-style `:param` segments are normalized automatically `/users/:id` and `/users/:userId` both map to `/users/{id}` to match discovered traffic.
254
+ Express-style `:param` segments (`/users/:id`) are normalized automatically to match real traffic. Call this after your routes are registered so all paths are included.
136
255
 
137
- Use `auth: false` for public endpoints. Authenticated endpoints default to `auth: true`.
256
+ ---
138
257
 
139
- Shadow detection from declared endpoints only activates once your org has registered at least one endpoint, so it won't produce noise on fresh deployments before you call this method.
258
+ ## Secret scanner CLI
140
259
 
141
- ## Secret Scanner CLI
260
+ Scan your codebase for leaked secrets, API keys, and PII before they reach production.
142
261
 
143
- Scan your codebase for hardcoded secrets, API keys, BVN/NIN numbers, and PII before they reach production.
144
-
145
- Uses three detection layers:
146
- - **secretlint** — 50+ service-specific rules (AWS, GCP, GitHub, Stripe, Slack, Twilio, SendGrid, PostgreSQL connection strings, and more)
147
- - **Custom patterns** — Nigerian PII (BVN/NIN), card PANs, phone numbers
148
- - **Entropy analysis** (`--strict`) — catches unknown high-entropy secrets with no known prefix, using Shannon entropy scoring
149
-
150
- **Run without installing:**
151
262
  ```bash
263
+ # One-off scan (no install needed)
152
264
  npx @anomira/node-sdk scan ./src
153
- ```
154
265
 
155
- **If `@anomira/node-sdk` is already installed in your project:**
156
- ```bash
266
+ # If the package is already installed
157
267
  npx anomira scan ./src
158
268
  ```
159
269
 
160
270
  **Options:**
271
+
161
272
  ```bash
162
273
  npx @anomira/node-sdk scan ./src # scan a directory
163
274
  npx @anomira/node-sdk scan . # scan entire project
164
- npx @anomira/node-sdk scan ./src --strict # enable entropy analysis (catches unknown secrets)
165
- npx @anomira/node-sdk scan ./src --json # machine-readable JSON output for CI
166
- npx @anomira/node-sdk scan ./src --quiet # only print violations, no header
275
+ npx @anomira/node-sdk scan ./src --strict # entropy analysis (catches unknown secrets)
276
+ npx @anomira/node-sdk scan ./src --json # machine-readable JSON for CI
277
+ npx @anomira/node-sdk scan ./src --quiet # violations only, no header
167
278
  ```
168
279
 
169
- **What it detects:**
280
+ **Detects:**
170
281
 
171
282
  | Category | Examples |
172
283
  |---|---|
173
- | Cloud credentials | AWS access keys, GCP service account keys, Azure connection strings |
174
- | Source control | GitHub tokens (`ghp_`), GitLab tokens (`glpat-`), NPM tokens (`npm_`) |
175
- | Payment | Stripe keys (`sk_live_`), Paystack keys |
176
- | Communication | Slack tokens (`xoxb-`), Twilio credentials, SendGrid keys |
177
- | Database | Connection strings with embedded passwords (`postgresql://user:pass@host`) |
178
- | Auth | JWT tokens, generic API keys and bearer tokens |
179
- | Nigerian PII | BVN/NIN (11-digit), card PANs, Nigerian phone numbers |
180
- | Unknown secrets | High-entropy strings assigned to secret-like variables (`--strict`) |
181
-
182
- **Add to `package.json` for CI/CD:**
284
+ | Cloud | AWS access keys, GCP service accounts, Azure connection strings |
285
+ | Source control | GitHub (`ghp_`), GitLab (`glpat-`), NPM (`npm_`) tokens |
286
+ | Payment | Stripe (`sk_live_`), Paystack secret keys |
287
+ | Communication | Slack (`xoxb-`), Twilio, SendGrid |
288
+ | Database | Connection strings with embedded passwords |
289
+ | Auth | JWT tokens, bearer tokens, generic API keys |
290
+ | PII | BVN/NIN (11-digit), card PANs, Nigerian phone numbers |
291
+ | Unknown | High-entropy strings on secret-like variable names (`--strict`) |
292
+
293
+ Add to CI/CD — exits `1` if violations are found:
294
+
183
295
  ```json
184
296
  {
185
297
  "scripts": {
@@ -188,44 +300,75 @@ npx @anomira/node-sdk scan ./src --quiet # only print violations, no header
188
300
  }
189
301
  ```
190
302
 
191
- Exit code `0` = clean. Exit code `1` = violations found — use in CI to fail the build on leaked secrets.
303
+ ---
192
304
 
193
- ## Environment Variables
305
+ ## Graceful shutdown
194
306
 
195
- | Variable | Description |
196
- |---|---|
197
- | `SENTINEL_API_KEY` | Your Anomira API key |
198
- | `SENTINEL_APP_ID` | Your Anomira app ID |
199
- | `SENTINEL_INGEST_URL` | Ingest endpoint (from your dashboard) |
307
+ Always flush the event buffer before your process exits. Unflushed events may be lost on a hard kill.
200
308
 
201
- ## Configuration
309
+ ```ts
310
+ process.on("SIGTERM", async () => {
311
+ await anomira.flush();
312
+ process.exit(0);
313
+ });
202
314
 
203
- | Option | Type | Default | Description |
204
- |---|---|---|---|
205
- | `apiKey` | `string` | — | Your Anomira API key (required) |
206
- | `appId` | `string` | — | Your Anomira app ID (required) |
207
- | `ingestUrl` | `string` | Anomira cloud | Ingest endpoint URL |
208
- | `debug` | `boolean` | `false` | Log SDK activity to console |
209
- | `captureConsole` | `boolean` | `false` | Forward `console.*` calls to the Logs dashboard |
210
- | `service` | `string` | `"app"` | Service name tag for logs |
211
- | `detect.bruteForce` | `boolean` | `true` | Detect brute force login attempts |
212
- | `detect.rateAbuse` | `boolean` | `true` | Detect rate limit abuse |
213
- | `detect.pathTraversal` | `boolean` | `true` | Detect path traversal attempts |
214
- | `detect.xss` | `boolean` | `true` | Detect XSS in request bodies |
215
- | `detect.scanDetection` | `boolean` | `true` | Detect scanner/bot probing |
216
- | `detect.geoVelocity` | `boolean` | `true` | Detect impossible travel between logins |
315
+ // For Fastify with lifecycle hooks:
316
+ app.addHook("onClose", async () => {
317
+ await anomira.flush();
318
+ });
319
+ ```
217
320
 
218
- ## Graceful Shutdown
321
+ ---
219
322
 
220
- Always flush pending events before your process exits:
323
+ ## Environment variables reference
221
324
 
222
- ```js
223
- process.on("SIGTERM", async () => {
224
- await sentinel.flush();
225
- process.exit(0);
226
- });
325
+ | Variable | Required | Description |
326
+ |---|---|---|
327
+ | `ANOMIRA_API_KEY` | ✅ | Your API key — from dashboard under **Apps → Setup** |
328
+ | `ANOMIRA_APP_ID` | ✅ | Your app ID — from dashboard under **Apps → Setup** |
329
+
330
+ ---
331
+
332
+ ## Configuration reference
333
+
334
+ | Option | Type | Default | Description |
335
+ |---|---|---|---|
336
+ | `apiKey` | `string` | — | API key (required) |
337
+ | `appId` | `string` | — | App ID (required) |
338
+ | `debug` | `boolean` | `false` | Log SDK activity to console |
339
+ | `service` | `string` | `"app"` | Service name tag on all log entries |
340
+ | `captureConsole` | `boolean` | `false` | Forward `console.*` to Logs dashboard |
341
+ | `detect.bruteForce` | `boolean` | `true` | Brute force detection on auth endpoints |
342
+ | `detect.rateAbuse` | `boolean` | `true` | High-rate abuse from single IP |
343
+ | `detect.pathTraversal` | `boolean` | `true` | Path traversal payloads in URLs |
344
+ | `detect.xss` | `boolean` | `true` | XSS payloads in request bodies |
345
+ | `detect.scanDetection` | `boolean` | `true` | Endpoint scanner / bot probing |
346
+ | `detect.geoVelocity` | `boolean` | `true` | Impossible travel between logins |
347
+
348
+ ---
349
+
350
+ ## Troubleshooting
351
+
352
+ **No events showing in the dashboard?**
353
+ 1. Set `debug: true` in the constructor — the SDK will log every event it sends to the console.
354
+ 2. Check that `ANOMIRA_API_KEY` and `ANOMIRA_APP_ID` are set in your process environment (`console.log(process.env.ANOMIRA_API_KEY)`).
355
+ 3. Confirm the middleware is registered **before** your routes and **after** body parsers.
356
+ 4. Make sure you're not blocking outbound HTTPS traffic to `api.anomira.io`.
357
+
358
+ **TypeScript errors on `req.ip`?**
359
+ Express types sometimes don't include `ip` directly. Use `(req as express.Request).ip ?? req.socket.remoteAddress ?? "0.0.0.0"`.
360
+
361
+ **`flush()` taking too long on shutdown?**
362
+ The flush waits for in-flight HTTP requests to complete. If your process needs to exit fast, you can add a timeout:
363
+ ```ts
364
+ await Promise.race([
365
+ anomira.flush(),
366
+ new Promise((resolve) => setTimeout(resolve, 3000)),
367
+ ]);
227
368
  ```
228
369
 
370
+ ---
371
+
229
372
  ## License
230
373
 
231
374
  MIT
package/dist/index.cjs CHANGED
@@ -671,6 +671,30 @@ var AnomiraClient = class {
671
671
  if (this.disabled) return false;
672
672
  return this.blockedIpCache.has(ip);
673
673
  }
674
+ /**
675
+ * Fire-and-forget: report a blocked-IP attempt to the ingest server so the
676
+ * dashboard can show that the block is actively working.
677
+ * Called automatically by the Express/Fastify middleware — no manual call needed.
678
+ */
679
+ reportBlockedHit(ip, meta) {
680
+ if (this.disabled) return;
681
+ const blockedHitUrl = this.config.ingestUrl.replace(/\/v1\/events$/, "/v1/blocked-hit");
682
+ fetch(blockedHitUrl, {
683
+ method: "POST",
684
+ headers: {
685
+ "Content-Type": "application/json",
686
+ "Authorization": `Bearer ${this.config.apiKey}`
687
+ },
688
+ body: JSON.stringify({
689
+ appId: this.config.appId,
690
+ ip,
691
+ method: meta.method,
692
+ endpoint: meta.url,
693
+ userAgent: meta.userAgent,
694
+ ts: Date.now()
695
+ })
696
+ }).catch(() => null);
697
+ }
674
698
  /** Evaluate firewall rules against a request. Returns the matched rule or null. Synchronous. */
675
699
  matchFirewallRule(req) {
676
700
  return this.#matchFirewallRule(req);
@@ -863,6 +887,10 @@ function createExpressMiddleware(client) {
863
887
  const startMs = Date.now();
864
888
  const ip = client.config.getIp(req);
865
889
  if (client.isBlocked(ip)) {
890
+ const method_ = req["method"]?.toUpperCase() ?? "GET";
891
+ const url_ = req["originalUrl"] ?? req["url"] ?? "/";
892
+ const ua_ = req["headers"]?.["user-agent"] ?? "";
893
+ client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: ua_ });
866
894
  const res_ = res;
867
895
  if (typeof res_["status"] === "function") {
868
896
  res_["status"](403);
@@ -938,7 +966,11 @@ function createFastifyPlugin(client) {
938
966
  return async function sentinelFastifyPlugin(fastify) {
939
967
  fastify.addHook("onRequest", (req, reply) => {
940
968
  const ip = client.config.getIp(req);
969
+ const url_ = req["url"] ?? "/";
970
+ const method_ = req["method"]?.toUpperCase() ?? "GET";
971
+ const hdrs_ = req["headers"];
941
972
  if (client.isBlocked(ip)) {
973
+ client.reportBlockedHit(ip, { method: method_, url: url_, userAgent: hdrs_?.["user-agent"] ?? "" });
942
974
  const rep = reply;
943
975
  if (typeof rep["code"] === "function") {
944
976
  const chained = rep["code"](403);