@anomira/node-sdk 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -67,6 +67,10 @@ interface AnomiraConfig {
67
67
  scanDetection?: boolean;
68
68
  /** Detect impossible travel between login events */
69
69
  geoVelocity?: boolean;
70
+ /** Detect SSRF payloads in request parameters (url, redirect, webhook, etc.) */
71
+ ssrf?: boolean;
72
+ /** Detect JWT header manipulation (alg:none, unknown algorithm, algorithm confusion, missing signature) */
73
+ jwtManipulation?: boolean;
70
74
  };
71
75
  /**
72
76
  * Custom function to extract userId from the request.
@@ -143,6 +147,8 @@ declare const EventName: {
143
147
  readonly RATE_LIMIT: "http.ratelimit.exceeded";
144
148
  readonly XSS_DETECTED: "http.xss.detected";
145
149
  readonly PATH_TRAVERSAL: "http.path.traversal";
150
+ readonly SSRF_ATTEMPT: "http.ssrf.attempt";
151
+ readonly JWT_MANIPULATION: "http.jwt.manipulation";
146
152
  readonly SCAN_DETECTED: "http.scan.detected";
147
153
  readonly IDOR_ATTEMPT: "user.idor.attempt";
148
154
  readonly SQL_ERROR: "db.sql.error";
package/dist/index.d.ts CHANGED
@@ -67,6 +67,10 @@ interface AnomiraConfig {
67
67
  scanDetection?: boolean;
68
68
  /** Detect impossible travel between login events */
69
69
  geoVelocity?: boolean;
70
+ /** Detect SSRF payloads in request parameters (url, redirect, webhook, etc.) */
71
+ ssrf?: boolean;
72
+ /** Detect JWT header manipulation (alg:none, unknown algorithm, algorithm confusion, missing signature) */
73
+ jwtManipulation?: boolean;
70
74
  };
71
75
  /**
72
76
  * Custom function to extract userId from the request.
@@ -143,6 +147,8 @@ declare const EventName: {
143
147
  readonly RATE_LIMIT: "http.ratelimit.exceeded";
144
148
  readonly XSS_DETECTED: "http.xss.detected";
145
149
  readonly PATH_TRAVERSAL: "http.path.traversal";
150
+ readonly SSRF_ATTEMPT: "http.ssrf.attempt";
151
+ readonly JWT_MANIPULATION: "http.jwt.manipulation";
146
152
  readonly SCAN_DETECTED: "http.scan.detected";
147
153
  readonly IDOR_ATTEMPT: "user.idor.attempt";
148
154
  readonly SQL_ERROR: "db.sql.error";
package/dist/index.js CHANGED
@@ -71,7 +71,7 @@ var EventBuffer = class {
71
71
  headers: {
72
72
  Authorization: `Bearer ${apiKey}`,
73
73
  "Content-Type": "application/json",
74
- "User-Agent": `@sentinelapi/node-sdk/0.1.0`
74
+ "User-Agent": `@anomira/node-sdk/0.2.5`
75
75
  },
76
76
  body: JSON.stringify(payload),
77
77
  signal: AbortSignal.timeout(8e3)
@@ -395,6 +395,8 @@ var EventName = {
395
395
  RATE_LIMIT: "http.ratelimit.exceeded",
396
396
  XSS_DETECTED: "http.xss.detected",
397
397
  PATH_TRAVERSAL: "http.path.traversal",
398
+ SSRF_ATTEMPT: "http.ssrf.attempt",
399
+ JWT_MANIPULATION: "http.jwt.manipulation",
398
400
  SCAN_DETECTED: "http.scan.detected",
399
401
  IDOR_ATTEMPT: "user.idor.attempt",
400
402
  SQL_ERROR: "db.sql.error",
@@ -618,6 +620,261 @@ function str(v) {
618
620
  if (v === void 0) return "";
619
621
  return Array.isArray(v) ? v[0] ?? "" : v;
620
622
  }
623
+
624
+ // src/ssrf.ts
625
+ var URL_PARAM_PATTERN = /^(url|uri|path|link|next|target|src|href|redirect|redirecturl|returnurl|successurl|callback|fetch|image|imageurl|avatar|photo|webhook|endpoint|to|host|domain|site|page|file|load|open|download|import|include|embed|source|proxy|destination|dest|resource|location|goto|return|referer|origin|remote|ping|pull)$/i;
626
+ var DANGEROUS_SCHEMES = /* @__PURE__ */ new Set([
627
+ "file",
628
+ "gopher",
629
+ "dict",
630
+ "ftp",
631
+ "sftp",
632
+ "ldap",
633
+ "ldaps",
634
+ "netdoc",
635
+ "jar",
636
+ "mailto",
637
+ "telnet",
638
+ "tftp",
639
+ "finger"
640
+ ]);
641
+ function intToIp(n) {
642
+ return [
643
+ n >>> 24 & 255,
644
+ n >>> 16 & 255,
645
+ n >>> 8 & 255,
646
+ n & 255
647
+ ].join(".");
648
+ }
649
+ function parseOctet(s) {
650
+ s = s.trim();
651
+ if (/^0x[0-9a-fA-F]+$/.test(s)) return parseInt(s, 16);
652
+ if (/^0[0-9]+$/.test(s)) return parseInt(s, 8);
653
+ if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
654
+ return null;
655
+ }
656
+ function normalizeIp(hostname) {
657
+ const h = hostname.trim().toLowerCase();
658
+ const v6mapped = h.match(/^(?:::ffff:)([0-9a-f]{1,4}:[0-9a-f]{1,4})$/i) ?? h.match(/^(?:0{0,4}:){5}ffff:(\d+\.\d+\.\d+\.\d+)$/i);
659
+ if (v6mapped?.[1]) {
660
+ const parts2 = v6mapped[1].split(":");
661
+ if (parts2.length === 2) {
662
+ const hi = parseInt(parts2[0], 16);
663
+ const lo = parseInt(parts2[1], 16);
664
+ if (!isNaN(hi) && !isNaN(lo)) return intToIp(hi << 16 | lo);
665
+ }
666
+ return normalizeIp(v6mapped[1]);
667
+ }
668
+ if (h === "::1" || h === "0:0:0:0:0:0:0:1") return "127.0.0.1";
669
+ if (/^0x[0-9a-f]+$/i.test(h)) {
670
+ const n = parseInt(h, 16);
671
+ if (!isNaN(n) && n >= 0 && n <= 4294967295) return intToIp(n);
672
+ }
673
+ if (/^\d+$/.test(h)) {
674
+ const n = parseInt(h, 10);
675
+ if (!isNaN(n) && n >= 0 && n <= 4294967295) return intToIp(n);
676
+ }
677
+ const parts = h.split(".");
678
+ if (parts.length >= 1 && parts.length <= 4) {
679
+ const octets = [];
680
+ for (const p of parts) {
681
+ const v = parseOctet(p);
682
+ if (v === null || v < 0 || v > 255) break;
683
+ octets.push(v);
684
+ }
685
+ if (octets.length === 4) return octets.join(".");
686
+ if (octets.length === 2) return `${octets[0]}.0.0.${octets[1]}`;
687
+ }
688
+ return null;
689
+ }
690
+ function ipToInt(ip) {
691
+ const parts = ip.split(".").map(Number);
692
+ return (parts[0] << 24 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
693
+ }
694
+ var PRIVATE_RANGES2 = [
695
+ { start: ipToInt("0.0.0.0"), end: ipToInt("0.255.255.255"), label: "unspecified" },
696
+ { start: ipToInt("10.0.0.0"), end: ipToInt("10.255.255.255"), label: "private-10" },
697
+ { start: ipToInt("100.64.0.0"), end: ipToInt("100.127.255.255"), label: "carrier-nat" },
698
+ { start: ipToInt("127.0.0.0"), end: ipToInt("127.255.255.255"), label: "loopback" },
699
+ { start: ipToInt("169.254.0.0"), end: ipToInt("169.254.255.255"), label: "link-local" },
700
+ // AWS/GCP metadata lives here
701
+ { start: ipToInt("172.16.0.0"), end: ipToInt("172.31.255.255"), label: "private-172" },
702
+ { start: ipToInt("192.0.0.0"), end: ipToInt("192.0.0.255"), label: "iana-special" },
703
+ // Oracle Cloud metadata
704
+ { start: ipToInt("192.168.0.0"), end: ipToInt("192.168.255.255"), label: "private-192" },
705
+ { start: ipToInt("198.18.0.0"), end: ipToInt("198.19.255.255"), label: "benchmark" },
706
+ { start: ipToInt("240.0.0.0"), end: ipToInt("255.255.255.255"), label: "reserved" },
707
+ // Specific cloud metadata endpoints not covered by ranges above
708
+ { start: ipToInt("100.100.100.200"), end: ipToInt("100.100.100.200"), label: "alibaba-metadata" },
709
+ { start: ipToInt("168.63.129.16"), end: ipToInt("168.63.129.16"), label: "azure-metadata" }
710
+ ];
711
+ function isPrivateIp2(ip) {
712
+ const n = ipToInt(ip);
713
+ for (const range of PRIVATE_RANGES2) {
714
+ if (n >= range.start && n <= range.end) return { private: true, label: range.label };
715
+ }
716
+ return { private: false, label: "" };
717
+ }
718
+ var INTERNAL_HOSTNAMES = /* @__PURE__ */ new Set([
719
+ "localhost",
720
+ "local",
721
+ "localdomain",
722
+ "metadata",
723
+ "metadata.google.internal",
724
+ "169.254.169.254",
725
+ // canonical — also caught by range check
726
+ "instance-data"
727
+ // AWS internal alias
728
+ ]);
729
+ function checkUrl(rawUrl, fieldName) {
730
+ if (!rawUrl.includes("://") && !rawUrl.startsWith("//")) return null;
731
+ let parsed;
732
+ try {
733
+ const withScheme = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
734
+ parsed = new URL(withScheme);
735
+ } catch {
736
+ return null;
737
+ }
738
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
739
+ const hostname = parsed.hostname.toLowerCase().replace(/\[|\]/g, "");
740
+ if (DANGEROUS_SCHEMES.has(scheme)) {
741
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `dangerous-scheme:${scheme}` };
742
+ }
743
+ if (scheme !== "http" && scheme !== "https") return null;
744
+ if (INTERNAL_HOSTNAMES.has(hostname)) {
745
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `internal-hostname:${hostname}` };
746
+ }
747
+ const normalized = normalizeIp(hostname);
748
+ if (normalized) {
749
+ const { private: isPrivate, label } = isPrivateIp2(normalized);
750
+ if (isPrivate) {
751
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `private-ip:${label}` };
752
+ }
753
+ }
754
+ return null;
755
+ }
756
+ function scanForSsrf(body, query) {
757
+ const candidates = [];
758
+ for (const [key, val] of Object.entries(query)) {
759
+ if (!URL_PARAM_PATTERN.test(key)) continue;
760
+ const v = Array.isArray(val) ? val[0] : val;
761
+ if (typeof v === "string" && v.length > 0) candidates.push({ name: key, value: v });
762
+ }
763
+ if (body && typeof body === "object" && !Array.isArray(body)) {
764
+ const flat = body;
765
+ for (const [key, val] of Object.entries(flat)) {
766
+ if (!URL_PARAM_PATTERN.test(key)) continue;
767
+ if (typeof val === "string" && val.length > 0) candidates.push({ name: key, value: val });
768
+ }
769
+ }
770
+ for (const { name, value } of candidates) {
771
+ const signal = checkUrl(value, name);
772
+ if (signal) return signal;
773
+ }
774
+ return null;
775
+ }
776
+
777
+ // src/jwt-detect.ts
778
+ var STANDARD_ALGORITHMS = /* @__PURE__ */ new Set([
779
+ // HMAC (symmetric)
780
+ "HS256",
781
+ "HS384",
782
+ "HS512",
783
+ // RSA PKCS#1 (asymmetric)
784
+ "RS256",
785
+ "RS384",
786
+ "RS512",
787
+ // ECDSA (asymmetric)
788
+ "ES256",
789
+ "ES384",
790
+ "ES512",
791
+ // RSA-PSS (asymmetric)
792
+ "PS256",
793
+ "PS384",
794
+ "PS512",
795
+ // Edwards-curve (RFC 8037)
796
+ "EdDSA"
797
+ ]);
798
+ var HMAC_ALGORITHMS = /* @__PURE__ */ new Set(["HS256", "HS384", "HS512"]);
799
+ var MAX_HMAC_SIG_LENGTH = 128;
800
+ function decodeBase64Url(s) {
801
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
802
+ const pad = (4 - padded.length % 4) % 4;
803
+ return Buffer.from(padded + "=".repeat(pad), "base64").toString("utf8");
804
+ }
805
+ function analyseJwt(token) {
806
+ const clean = token.trim();
807
+ const parts = clean.split(".");
808
+ if (parts.length < 3 || parts[2] === "") {
809
+ return {
810
+ detected: true,
811
+ attack: "missing_signature",
812
+ alg: null,
813
+ detail: `JWT has ${parts.length} segment(s) \u2014 signature is missing or empty.`
814
+ };
815
+ }
816
+ let header;
817
+ try {
818
+ header = JSON.parse(decodeBase64Url(parts[0]));
819
+ } catch {
820
+ return { detected: false, attack: null, alg: null, detail: "" };
821
+ }
822
+ const alg = typeof header["alg"] === "string" ? header["alg"] : null;
823
+ if (!alg) {
824
+ return { detected: false, attack: null, alg: null, detail: "" };
825
+ }
826
+ if (alg.toLowerCase() === "none") {
827
+ return {
828
+ detected: true,
829
+ attack: "alg_none",
830
+ alg,
831
+ detail: `JWT header specifies alg="${alg}" \u2014 server told to skip signature verification.`
832
+ };
833
+ }
834
+ if (!STANDARD_ALGORITHMS.has(alg)) {
835
+ return {
836
+ detected: true,
837
+ attack: "unknown_algorithm",
838
+ alg,
839
+ detail: `JWT header specifies non-standard alg="${alg}" \u2014 not in RFC 7518 algorithm set.`
840
+ };
841
+ }
842
+ if (HMAC_ALGORITHMS.has(alg) && parts[2].length > MAX_HMAC_SIG_LENGTH) {
843
+ return {
844
+ detected: true,
845
+ attack: "algorithm_confusion",
846
+ alg,
847
+ detail: `JWT claims ${alg} (HMAC) but signature is ${parts[2].length} chars \u2014 characteristic of RSA key used as HMAC secret (algorithm confusion attack).`
848
+ };
849
+ }
850
+ return { detected: false, attack: null, alg, detail: "" };
851
+ }
852
+ function scanRequestForJwtAttacks(headers, body, query) {
853
+ const candidates = [];
854
+ const auth = headers["authorization"];
855
+ const authStr = Array.isArray(auth) ? auth[0] : auth;
856
+ if (typeof authStr === "string" && authStr.toLowerCase().startsWith("bearer ")) {
857
+ candidates.push(authStr.slice(7).trim());
858
+ }
859
+ const TOKEN_FIELDS = /* @__PURE__ */ new Set(["token", "jwt", "access_token", "accessToken", "id_token", "idToken", "refresh_token", "refreshToken"]);
860
+ if (body && typeof body === "object" && !Array.isArray(body)) {
861
+ for (const [key, val] of Object.entries(body)) {
862
+ if (TOKEN_FIELDS.has(key) && typeof val === "string" && val.includes(".")) {
863
+ candidates.push(val);
864
+ }
865
+ }
866
+ }
867
+ for (const field of ["token", "jwt", "access_token"]) {
868
+ const val = query[field];
869
+ const str2 = Array.isArray(val) ? val[0] : val;
870
+ if (typeof str2 === "string" && str2.includes(".")) candidates.push(str2);
871
+ }
872
+ for (const token of candidates) {
873
+ const result = analyseJwt(token);
874
+ if (result.detected) return result;
875
+ }
876
+ return null;
877
+ }
621
878
  function detectHoneypotType(path) {
622
879
  const p = path.toLowerCase().split("?")[0] ?? path.toLowerCase();
623
880
  if (/\/\.env(\.[\w]+)?$/.test(p) || p.endsWith("/.env")) return "env_file";
@@ -1310,7 +1567,7 @@ var AnomiraClient = class {
1310
1567
  service: "app",
1311
1568
  getUserId: defaultGetUserId,
1312
1569
  getIp: defaultGetIp,
1313
- detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true },
1570
+ detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true, ssrf: true, jwtManipulation: true },
1314
1571
  autoBlock: { enabled: true, communityThreshold: 85 }
1315
1572
  };
1316
1573
  this.buffer = new EventBuffer({ appId: "", apiKey: "", ingestUrl: DEFAULT_INGEST_URL, maxBatchSize: 0, flushIntervalMs: 999999999, maxRetries: 0, debug: false });
@@ -1335,7 +1592,9 @@ var AnomiraClient = class {
1335
1592
  pathTraversal: config.detect?.pathTraversal ?? true,
1336
1593
  xss: config.detect?.xss ?? true,
1337
1594
  scanDetection: config.detect?.scanDetection ?? true,
1338
- geoVelocity: config.detect?.geoVelocity ?? true
1595
+ geoVelocity: config.detect?.geoVelocity ?? true,
1596
+ ssrf: config.detect?.ssrf ?? true,
1597
+ jwtManipulation: config.detect?.jwtManipulation ?? true
1339
1598
  },
1340
1599
  autoBlock: {
1341
1600
  enabled: config.autoBlock?.enabled ?? true,
@@ -1692,7 +1951,7 @@ var AnomiraClient = class {
1692
1951
  if (this.disabled) return;
1693
1952
  const ctx = requestContext.getStore();
1694
1953
  const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
1695
- if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
1954
+ if (this.config.debug && data.ip && isPrivateIp3(data.ip)) {
1696
1955
  this._origWarn(
1697
1956
  `[Anomira] Warning: track() received a private/loopback IP "${data.ip}" for event "${eventName}". Behind a reverse proxy, req.ip is the proxy's address \u2014 use sentinel.getClientIp(req) instead.`
1698
1957
  );
@@ -1931,13 +2190,13 @@ function pickId(obj) {
1931
2190
  obj["accountId"] ?? obj["account_id"] ?? obj["customerId"] ?? obj["customer_id"];
1932
2191
  return typeof id === "string" && id.length > 0 ? id : void 0;
1933
2192
  }
1934
- function normalizeIp(raw) {
2193
+ function normalizeIp2(raw) {
1935
2194
  if (raw === "::1") return "127.0.0.1";
1936
2195
  if (raw === "::ffff:127.0.0.1") return "127.0.0.1";
1937
2196
  if (raw.startsWith("::ffff:")) return raw.slice(7);
1938
2197
  return raw;
1939
2198
  }
1940
- function isPrivateIp2(ip) {
2199
+ function isPrivateIp3(ip) {
1941
2200
  return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("10.") || ip.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
1942
2201
  }
1943
2202
  function defaultGetIp(req) {
@@ -1957,7 +2216,7 @@ function defaultGetIp(req) {
1957
2216
  firstHdr("x-client-ip") ?? // Generic reverse proxies
1958
2217
  firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
1959
2218
  r["socket"]?.remoteAddress ?? "0.0.0.0";
1960
- return normalizeIp(ip);
2219
+ return normalizeIp2(ip);
1961
2220
  }
1962
2221
  function sendFakeResponse(res, fakeResponse) {
1963
2222
  const allHeaders = {
@@ -2200,6 +2459,31 @@ function createExpressMiddleware(client) {
2200
2459
  client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
2201
2460
  }
2202
2461
  }
2462
+ if (client.config.detect.ssrf) {
2463
+ const query = req["query"] ?? {};
2464
+ const body = req["body"] ?? {};
2465
+ const signal = scanForSsrf(body, query);
2466
+ if (signal) {
2467
+ client.track(EventName.SSRF_ATTEMPT, {
2468
+ ip,
2469
+ userId,
2470
+ meta: { url, method, ssrfPayload: signal.payload, ssrfField: signal.field, ssrfReason: signal.reason }
2471
+ });
2472
+ }
2473
+ }
2474
+ if (client.config.detect.jwtManipulation) {
2475
+ const reqHeaders = headers ?? {};
2476
+ const reqBody = req["body"] ?? {};
2477
+ const reqQuery = req["query"] ?? {};
2478
+ const jwtResult = scanRequestForJwtAttacks(reqHeaders, reqBody, reqQuery);
2479
+ if (jwtResult?.detected) {
2480
+ client.track(EventName.JWT_MANIPULATION, {
2481
+ ip,
2482
+ userId,
2483
+ meta: { url, method, jwtAttack: jwtResult.attack, jwtAlg: jwtResult.alg, jwtDetail: jwtResult.detail }
2484
+ });
2485
+ }
2486
+ }
2203
2487
  const onFinish = () => {
2204
2488
  const lateUserId = client.config.getUserId(req) || browserFp?.uid || "";
2205
2489
  if (!userIdWarnFired && userIdCheckCount < 20) {
@@ -2426,6 +2710,31 @@ function createFastifyPlugin(client) {
2426
2710
  client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
2427
2711
  }
2428
2712
  }
2713
+ if (client.config.detect.ssrf) {
2714
+ const query = req["query"] ?? {};
2715
+ const body = req["body"] ?? {};
2716
+ const signal = scanForSsrf(body, query);
2717
+ if (signal) {
2718
+ client.track(EventName.SSRF_ATTEMPT, {
2719
+ ip,
2720
+ userId,
2721
+ meta: { url, method, ssrfPayload: signal.payload, ssrfField: signal.field, ssrfReason: signal.reason }
2722
+ });
2723
+ }
2724
+ }
2725
+ if (client.config.detect.jwtManipulation) {
2726
+ const fHeaders = req["headers"] ?? {};
2727
+ const fBody = req["body"] ?? {};
2728
+ const fQuery = req["query"] ?? {};
2729
+ const jwtRes = scanRequestForJwtAttacks(fHeaders, fBody, fQuery);
2730
+ if (jwtRes?.detected) {
2731
+ client.track(EventName.JWT_MANIPULATION, {
2732
+ ip,
2733
+ userId,
2734
+ meta: { url, method, jwtAttack: jwtRes.attack, jwtAlg: jwtRes.alg, jwtDetail: jwtRes.detail }
2735
+ });
2736
+ }
2737
+ }
2429
2738
  });
2430
2739
  fastify.addHook("onResponse", (req, reply) => {
2431
2740
  const ip = client.config.getIp(req);
@@ -2581,23 +2890,40 @@ function createExpressMiddleware2(client) {
2581
2890
  } catch {
2582
2891
  }
2583
2892
  }
2893
+ const startTs = process.hrtime.bigint();
2894
+ let resSize = 0;
2895
+ let resFieldCount = 0;
2896
+ const origJson = res.json.bind(res);
2897
+ res.json = function anomiraJson(body) {
2898
+ try {
2899
+ if (body !== null && typeof body === "object" && !Array.isArray(body)) {
2900
+ resFieldCount = Object.keys(body).length;
2901
+ }
2902
+ const serialised = JSON.stringify(body);
2903
+ resSize = Buffer.byteLength(serialised, "utf8");
2904
+ } catch {
2905
+ }
2906
+ return origJson(body);
2907
+ };
2908
+ const origSend = res.send.bind(res);
2909
+ res.send = function anomiraSend(body) {
2910
+ if (resSize === 0) {
2911
+ if (typeof body === "string") resSize = Buffer.byteLength(body, "utf8");
2912
+ else if (Buffer.isBuffer(body)) resSize = body.length;
2913
+ }
2914
+ return origSend(body);
2915
+ };
2584
2916
  const onFinish = () => {
2585
2917
  res.off("finish", onFinish);
2586
2918
  const { statusCode } = res;
2919
+ const resTimeMs = Math.round(Number(process.hrtime.bigint() - startTs) / 1e6);
2920
+ const resMeta = { url, method, statusCode, resTimeMs, resSize, resFieldCount };
2587
2921
  if (client.config.detect.rateAbuse && statusCode === 429) {
2588
- client.track(EventName.RATE_LIMIT, {
2589
- ip,
2590
- userId,
2591
- meta: { url, method, statusCode }
2592
- });
2922
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: resMeta });
2593
2923
  }
2594
2924
  if (client.config.detect.bruteForce && statusCode === 401) {
2595
2925
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2596
- client.track(EventName.LOGIN_FAILED, {
2597
- ip,
2598
- userId,
2599
- meta: { url, method, statusCode }
2600
- });
2926
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: resMeta });
2601
2927
  }
2602
2928
  }
2603
2929
  if (client.config.detect.scanDetection && statusCode === 404) {
@@ -2606,14 +2932,14 @@ function createExpressMiddleware2(client) {
2606
2932
  client.track(EventName.SCAN_DETECTED, {
2607
2933
  ip,
2608
2934
  userId,
2609
- meta: { url, method, userAgent: ua }
2935
+ meta: { ...resMeta, userAgent: ua }
2610
2936
  });
2611
2937
  }
2612
2938
  }
2613
2939
  if (client.config.detect.geoVelocity && statusCode === 200 && /\/(login|signin|auth|token)/i.test(url) && method === "POST") {
2614
2940
  const resolvedUserId = client.config.getUserId(req);
2615
2941
  if (resolvedUserId) {
2616
- void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
2942
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: resMeta });
2617
2943
  }
2618
2944
  }
2619
2945
  };
@@ -2653,30 +2979,52 @@ function createFastifyPlugin2(client) {
2653
2979
  }
2654
2980
  }
2655
2981
  });
2982
+ fastify.addHook("onSend", async (_req, _reply, payload) => {
2983
+ try {
2984
+ if (typeof payload === "string") {
2985
+ _req._anomiraResSize = Buffer.byteLength(payload, "utf8");
2986
+ try {
2987
+ const parsed = JSON.parse(payload);
2988
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2989
+ _req._anomiraResFields = Object.keys(parsed).length;
2990
+ }
2991
+ } catch {
2992
+ }
2993
+ } else if (Buffer.isBuffer(payload)) {
2994
+ _req._anomiraResSize = payload.length;
2995
+ }
2996
+ } catch {
2997
+ }
2998
+ return payload;
2999
+ });
2656
3000
  fastify.addHook("onResponse", async (req, reply) => {
2657
3001
  const ip = client.config.getIp(req);
2658
3002
  const userId = client.config.getUserId(req);
2659
3003
  const url = req.url;
2660
3004
  const method = req.method.toUpperCase();
2661
3005
  const statusCode = reply.statusCode;
3006
+ const resTimeMs = Math.round(reply.elapsedTime ?? 0);
3007
+ const resSize = req._anomiraResSize ?? 0;
3008
+ const resFieldCount = req._anomiraResFields ?? 0;
3009
+ const resMeta = { url, method, statusCode, resTimeMs, resSize, resFieldCount };
2662
3010
  if (client.config.detect.rateAbuse && statusCode === 429) {
2663
- client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode } });
3011
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: resMeta });
2664
3012
  }
2665
3013
  if (client.config.detect.bruteForce && statusCode === 401) {
2666
3014
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2667
- client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode } });
3015
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: resMeta });
2668
3016
  }
2669
3017
  }
2670
3018
  if (client.config.detect.scanDetection && statusCode === 404) {
2671
3019
  const ua = req.headers["user-agent"] ?? "";
2672
3020
  if (!ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua)) {
2673
- client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
3021
+ client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { ...resMeta, userAgent: ua } });
2674
3022
  }
2675
3023
  }
2676
3024
  if (client.config.detect.geoVelocity && statusCode === 200 && method === "POST" && /\/(login|signin|auth|token)/i.test(url)) {
2677
3025
  const resolvedUserId = client.config.getUserId(req);
2678
3026
  if (resolvedUserId) {
2679
- void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
3027
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: resMeta });
2680
3028
  }
2681
3029
  }
2682
3030
  });