@anomira/node-sdk 0.2.6 → 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.cjs CHANGED
@@ -399,6 +399,8 @@ var EventName = {
399
399
  RATE_LIMIT: "http.ratelimit.exceeded",
400
400
  XSS_DETECTED: "http.xss.detected",
401
401
  PATH_TRAVERSAL: "http.path.traversal",
402
+ SSRF_ATTEMPT: "http.ssrf.attempt",
403
+ JWT_MANIPULATION: "http.jwt.manipulation",
402
404
  SCAN_DETECTED: "http.scan.detected",
403
405
  IDOR_ATTEMPT: "user.idor.attempt",
404
406
  SQL_ERROR: "db.sql.error",
@@ -622,6 +624,261 @@ function str(v) {
622
624
  if (v === void 0) return "";
623
625
  return Array.isArray(v) ? v[0] ?? "" : v;
624
626
  }
627
+
628
+ // src/ssrf.ts
629
+ 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;
630
+ var DANGEROUS_SCHEMES = /* @__PURE__ */ new Set([
631
+ "file",
632
+ "gopher",
633
+ "dict",
634
+ "ftp",
635
+ "sftp",
636
+ "ldap",
637
+ "ldaps",
638
+ "netdoc",
639
+ "jar",
640
+ "mailto",
641
+ "telnet",
642
+ "tftp",
643
+ "finger"
644
+ ]);
645
+ function intToIp(n) {
646
+ return [
647
+ n >>> 24 & 255,
648
+ n >>> 16 & 255,
649
+ n >>> 8 & 255,
650
+ n & 255
651
+ ].join(".");
652
+ }
653
+ function parseOctet(s) {
654
+ s = s.trim();
655
+ if (/^0x[0-9a-fA-F]+$/.test(s)) return parseInt(s, 16);
656
+ if (/^0[0-9]+$/.test(s)) return parseInt(s, 8);
657
+ if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
658
+ return null;
659
+ }
660
+ function normalizeIp(hostname) {
661
+ const h = hostname.trim().toLowerCase();
662
+ 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);
663
+ if (v6mapped?.[1]) {
664
+ const parts2 = v6mapped[1].split(":");
665
+ if (parts2.length === 2) {
666
+ const hi = parseInt(parts2[0], 16);
667
+ const lo = parseInt(parts2[1], 16);
668
+ if (!isNaN(hi) && !isNaN(lo)) return intToIp(hi << 16 | lo);
669
+ }
670
+ return normalizeIp(v6mapped[1]);
671
+ }
672
+ if (h === "::1" || h === "0:0:0:0:0:0:0:1") return "127.0.0.1";
673
+ if (/^0x[0-9a-f]+$/i.test(h)) {
674
+ const n = parseInt(h, 16);
675
+ if (!isNaN(n) && n >= 0 && n <= 4294967295) return intToIp(n);
676
+ }
677
+ if (/^\d+$/.test(h)) {
678
+ const n = parseInt(h, 10);
679
+ if (!isNaN(n) && n >= 0 && n <= 4294967295) return intToIp(n);
680
+ }
681
+ const parts = h.split(".");
682
+ if (parts.length >= 1 && parts.length <= 4) {
683
+ const octets = [];
684
+ for (const p of parts) {
685
+ const v = parseOctet(p);
686
+ if (v === null || v < 0 || v > 255) break;
687
+ octets.push(v);
688
+ }
689
+ if (octets.length === 4) return octets.join(".");
690
+ if (octets.length === 2) return `${octets[0]}.0.0.${octets[1]}`;
691
+ }
692
+ return null;
693
+ }
694
+ function ipToInt(ip) {
695
+ const parts = ip.split(".").map(Number);
696
+ return (parts[0] << 24 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
697
+ }
698
+ var PRIVATE_RANGES2 = [
699
+ { start: ipToInt("0.0.0.0"), end: ipToInt("0.255.255.255"), label: "unspecified" },
700
+ { start: ipToInt("10.0.0.0"), end: ipToInt("10.255.255.255"), label: "private-10" },
701
+ { start: ipToInt("100.64.0.0"), end: ipToInt("100.127.255.255"), label: "carrier-nat" },
702
+ { start: ipToInt("127.0.0.0"), end: ipToInt("127.255.255.255"), label: "loopback" },
703
+ { start: ipToInt("169.254.0.0"), end: ipToInt("169.254.255.255"), label: "link-local" },
704
+ // AWS/GCP metadata lives here
705
+ { start: ipToInt("172.16.0.0"), end: ipToInt("172.31.255.255"), label: "private-172" },
706
+ { start: ipToInt("192.0.0.0"), end: ipToInt("192.0.0.255"), label: "iana-special" },
707
+ // Oracle Cloud metadata
708
+ { start: ipToInt("192.168.0.0"), end: ipToInt("192.168.255.255"), label: "private-192" },
709
+ { start: ipToInt("198.18.0.0"), end: ipToInt("198.19.255.255"), label: "benchmark" },
710
+ { start: ipToInt("240.0.0.0"), end: ipToInt("255.255.255.255"), label: "reserved" },
711
+ // Specific cloud metadata endpoints not covered by ranges above
712
+ { start: ipToInt("100.100.100.200"), end: ipToInt("100.100.100.200"), label: "alibaba-metadata" },
713
+ { start: ipToInt("168.63.129.16"), end: ipToInt("168.63.129.16"), label: "azure-metadata" }
714
+ ];
715
+ function isPrivateIp2(ip) {
716
+ const n = ipToInt(ip);
717
+ for (const range of PRIVATE_RANGES2) {
718
+ if (n >= range.start && n <= range.end) return { private: true, label: range.label };
719
+ }
720
+ return { private: false, label: "" };
721
+ }
722
+ var INTERNAL_HOSTNAMES = /* @__PURE__ */ new Set([
723
+ "localhost",
724
+ "local",
725
+ "localdomain",
726
+ "metadata",
727
+ "metadata.google.internal",
728
+ "169.254.169.254",
729
+ // canonical — also caught by range check
730
+ "instance-data"
731
+ // AWS internal alias
732
+ ]);
733
+ function checkUrl(rawUrl, fieldName) {
734
+ if (!rawUrl.includes("://") && !rawUrl.startsWith("//")) return null;
735
+ let parsed;
736
+ try {
737
+ const withScheme = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
738
+ parsed = new URL(withScheme);
739
+ } catch {
740
+ return null;
741
+ }
742
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
743
+ const hostname = parsed.hostname.toLowerCase().replace(/\[|\]/g, "");
744
+ if (DANGEROUS_SCHEMES.has(scheme)) {
745
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `dangerous-scheme:${scheme}` };
746
+ }
747
+ if (scheme !== "http" && scheme !== "https") return null;
748
+ if (INTERNAL_HOSTNAMES.has(hostname)) {
749
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `internal-hostname:${hostname}` };
750
+ }
751
+ const normalized = normalizeIp(hostname);
752
+ if (normalized) {
753
+ const { private: isPrivate, label } = isPrivateIp2(normalized);
754
+ if (isPrivate) {
755
+ return { detected: true, payload: rawUrl, field: fieldName, reason: `private-ip:${label}` };
756
+ }
757
+ }
758
+ return null;
759
+ }
760
+ function scanForSsrf(body, query) {
761
+ const candidates = [];
762
+ for (const [key, val] of Object.entries(query)) {
763
+ if (!URL_PARAM_PATTERN.test(key)) continue;
764
+ const v = Array.isArray(val) ? val[0] : val;
765
+ if (typeof v === "string" && v.length > 0) candidates.push({ name: key, value: v });
766
+ }
767
+ if (body && typeof body === "object" && !Array.isArray(body)) {
768
+ const flat = body;
769
+ for (const [key, val] of Object.entries(flat)) {
770
+ if (!URL_PARAM_PATTERN.test(key)) continue;
771
+ if (typeof val === "string" && val.length > 0) candidates.push({ name: key, value: val });
772
+ }
773
+ }
774
+ for (const { name, value } of candidates) {
775
+ const signal = checkUrl(value, name);
776
+ if (signal) return signal;
777
+ }
778
+ return null;
779
+ }
780
+
781
+ // src/jwt-detect.ts
782
+ var STANDARD_ALGORITHMS = /* @__PURE__ */ new Set([
783
+ // HMAC (symmetric)
784
+ "HS256",
785
+ "HS384",
786
+ "HS512",
787
+ // RSA PKCS#1 (asymmetric)
788
+ "RS256",
789
+ "RS384",
790
+ "RS512",
791
+ // ECDSA (asymmetric)
792
+ "ES256",
793
+ "ES384",
794
+ "ES512",
795
+ // RSA-PSS (asymmetric)
796
+ "PS256",
797
+ "PS384",
798
+ "PS512",
799
+ // Edwards-curve (RFC 8037)
800
+ "EdDSA"
801
+ ]);
802
+ var HMAC_ALGORITHMS = /* @__PURE__ */ new Set(["HS256", "HS384", "HS512"]);
803
+ var MAX_HMAC_SIG_LENGTH = 128;
804
+ function decodeBase64Url(s) {
805
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
806
+ const pad = (4 - padded.length % 4) % 4;
807
+ return Buffer.from(padded + "=".repeat(pad), "base64").toString("utf8");
808
+ }
809
+ function analyseJwt(token) {
810
+ const clean = token.trim();
811
+ const parts = clean.split(".");
812
+ if (parts.length < 3 || parts[2] === "") {
813
+ return {
814
+ detected: true,
815
+ attack: "missing_signature",
816
+ alg: null,
817
+ detail: `JWT has ${parts.length} segment(s) \u2014 signature is missing or empty.`
818
+ };
819
+ }
820
+ let header;
821
+ try {
822
+ header = JSON.parse(decodeBase64Url(parts[0]));
823
+ } catch {
824
+ return { detected: false, attack: null, alg: null, detail: "" };
825
+ }
826
+ const alg = typeof header["alg"] === "string" ? header["alg"] : null;
827
+ if (!alg) {
828
+ return { detected: false, attack: null, alg: null, detail: "" };
829
+ }
830
+ if (alg.toLowerCase() === "none") {
831
+ return {
832
+ detected: true,
833
+ attack: "alg_none",
834
+ alg,
835
+ detail: `JWT header specifies alg="${alg}" \u2014 server told to skip signature verification.`
836
+ };
837
+ }
838
+ if (!STANDARD_ALGORITHMS.has(alg)) {
839
+ return {
840
+ detected: true,
841
+ attack: "unknown_algorithm",
842
+ alg,
843
+ detail: `JWT header specifies non-standard alg="${alg}" \u2014 not in RFC 7518 algorithm set.`
844
+ };
845
+ }
846
+ if (HMAC_ALGORITHMS.has(alg) && parts[2].length > MAX_HMAC_SIG_LENGTH) {
847
+ return {
848
+ detected: true,
849
+ attack: "algorithm_confusion",
850
+ alg,
851
+ detail: `JWT claims ${alg} (HMAC) but signature is ${parts[2].length} chars \u2014 characteristic of RSA key used as HMAC secret (algorithm confusion attack).`
852
+ };
853
+ }
854
+ return { detected: false, attack: null, alg, detail: "" };
855
+ }
856
+ function scanRequestForJwtAttacks(headers, body, query) {
857
+ const candidates = [];
858
+ const auth = headers["authorization"];
859
+ const authStr = Array.isArray(auth) ? auth[0] : auth;
860
+ if (typeof authStr === "string" && authStr.toLowerCase().startsWith("bearer ")) {
861
+ candidates.push(authStr.slice(7).trim());
862
+ }
863
+ const TOKEN_FIELDS = /* @__PURE__ */ new Set(["token", "jwt", "access_token", "accessToken", "id_token", "idToken", "refresh_token", "refreshToken"]);
864
+ if (body && typeof body === "object" && !Array.isArray(body)) {
865
+ for (const [key, val] of Object.entries(body)) {
866
+ if (TOKEN_FIELDS.has(key) && typeof val === "string" && val.includes(".")) {
867
+ candidates.push(val);
868
+ }
869
+ }
870
+ }
871
+ for (const field of ["token", "jwt", "access_token"]) {
872
+ const val = query[field];
873
+ const str2 = Array.isArray(val) ? val[0] : val;
874
+ if (typeof str2 === "string" && str2.includes(".")) candidates.push(str2);
875
+ }
876
+ for (const token of candidates) {
877
+ const result = analyseJwt(token);
878
+ if (result.detected) return result;
879
+ }
880
+ return null;
881
+ }
625
882
  function detectHoneypotType(path) {
626
883
  const p = path.toLowerCase().split("?")[0] ?? path.toLowerCase();
627
884
  if (/\/\.env(\.[\w]+)?$/.test(p) || p.endsWith("/.env")) return "env_file";
@@ -1314,7 +1571,7 @@ var AnomiraClient = class {
1314
1571
  service: "app",
1315
1572
  getUserId: defaultGetUserId,
1316
1573
  getIp: defaultGetIp,
1317
- detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true },
1574
+ detect: { bruteForce: true, rateAbuse: true, pathTraversal: true, xss: true, scanDetection: true, geoVelocity: true, ssrf: true, jwtManipulation: true },
1318
1575
  autoBlock: { enabled: true, communityThreshold: 85 }
1319
1576
  };
1320
1577
  this.buffer = new EventBuffer({ appId: "", apiKey: "", ingestUrl: DEFAULT_INGEST_URL, maxBatchSize: 0, flushIntervalMs: 999999999, maxRetries: 0, debug: false });
@@ -1339,7 +1596,9 @@ var AnomiraClient = class {
1339
1596
  pathTraversal: config.detect?.pathTraversal ?? true,
1340
1597
  xss: config.detect?.xss ?? true,
1341
1598
  scanDetection: config.detect?.scanDetection ?? true,
1342
- geoVelocity: config.detect?.geoVelocity ?? true
1599
+ geoVelocity: config.detect?.geoVelocity ?? true,
1600
+ ssrf: config.detect?.ssrf ?? true,
1601
+ jwtManipulation: config.detect?.jwtManipulation ?? true
1343
1602
  },
1344
1603
  autoBlock: {
1345
1604
  enabled: config.autoBlock?.enabled ?? true,
@@ -1696,7 +1955,7 @@ var AnomiraClient = class {
1696
1955
  if (this.disabled) return;
1697
1956
  const ctx = requestContext.getStore();
1698
1957
  const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
1699
- if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
1958
+ if (this.config.debug && data.ip && isPrivateIp3(data.ip)) {
1700
1959
  this._origWarn(
1701
1960
  `[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.`
1702
1961
  );
@@ -1935,13 +2194,13 @@ function pickId(obj) {
1935
2194
  obj["accountId"] ?? obj["account_id"] ?? obj["customerId"] ?? obj["customer_id"];
1936
2195
  return typeof id === "string" && id.length > 0 ? id : void 0;
1937
2196
  }
1938
- function normalizeIp(raw) {
2197
+ function normalizeIp2(raw) {
1939
2198
  if (raw === "::1") return "127.0.0.1";
1940
2199
  if (raw === "::ffff:127.0.0.1") return "127.0.0.1";
1941
2200
  if (raw.startsWith("::ffff:")) return raw.slice(7);
1942
2201
  return raw;
1943
2202
  }
1944
- function isPrivateIp2(ip) {
2203
+ function isPrivateIp3(ip) {
1945
2204
  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);
1946
2205
  }
1947
2206
  function defaultGetIp(req) {
@@ -1961,7 +2220,7 @@ function defaultGetIp(req) {
1961
2220
  firstHdr("x-client-ip") ?? // Generic reverse proxies
1962
2221
  firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
1963
2222
  r["socket"]?.remoteAddress ?? "0.0.0.0";
1964
- return normalizeIp(ip);
2223
+ return normalizeIp2(ip);
1965
2224
  }
1966
2225
  function sendFakeResponse(res, fakeResponse) {
1967
2226
  const allHeaders = {
@@ -2204,6 +2463,31 @@ function createExpressMiddleware(client) {
2204
2463
  client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
2205
2464
  }
2206
2465
  }
2466
+ if (client.config.detect.ssrf) {
2467
+ const query = req["query"] ?? {};
2468
+ const body = req["body"] ?? {};
2469
+ const signal = scanForSsrf(body, query);
2470
+ if (signal) {
2471
+ client.track(EventName.SSRF_ATTEMPT, {
2472
+ ip,
2473
+ userId,
2474
+ meta: { url, method, ssrfPayload: signal.payload, ssrfField: signal.field, ssrfReason: signal.reason }
2475
+ });
2476
+ }
2477
+ }
2478
+ if (client.config.detect.jwtManipulation) {
2479
+ const reqHeaders = headers ?? {};
2480
+ const reqBody = req["body"] ?? {};
2481
+ const reqQuery = req["query"] ?? {};
2482
+ const jwtResult = scanRequestForJwtAttacks(reqHeaders, reqBody, reqQuery);
2483
+ if (jwtResult?.detected) {
2484
+ client.track(EventName.JWT_MANIPULATION, {
2485
+ ip,
2486
+ userId,
2487
+ meta: { url, method, jwtAttack: jwtResult.attack, jwtAlg: jwtResult.alg, jwtDetail: jwtResult.detail }
2488
+ });
2489
+ }
2490
+ }
2207
2491
  const onFinish = () => {
2208
2492
  const lateUserId = client.config.getUserId(req) || browserFp?.uid || "";
2209
2493
  if (!userIdWarnFired && userIdCheckCount < 20) {
@@ -2430,6 +2714,31 @@ function createFastifyPlugin(client) {
2430
2714
  client.track(EventName.XSS_DETECTED, { ip, userId, meta: { url, method } });
2431
2715
  }
2432
2716
  }
2717
+ if (client.config.detect.ssrf) {
2718
+ const query = req["query"] ?? {};
2719
+ const body = req["body"] ?? {};
2720
+ const signal = scanForSsrf(body, query);
2721
+ if (signal) {
2722
+ client.track(EventName.SSRF_ATTEMPT, {
2723
+ ip,
2724
+ userId,
2725
+ meta: { url, method, ssrfPayload: signal.payload, ssrfField: signal.field, ssrfReason: signal.reason }
2726
+ });
2727
+ }
2728
+ }
2729
+ if (client.config.detect.jwtManipulation) {
2730
+ const fHeaders = req["headers"] ?? {};
2731
+ const fBody = req["body"] ?? {};
2732
+ const fQuery = req["query"] ?? {};
2733
+ const jwtRes = scanRequestForJwtAttacks(fHeaders, fBody, fQuery);
2734
+ if (jwtRes?.detected) {
2735
+ client.track(EventName.JWT_MANIPULATION, {
2736
+ ip,
2737
+ userId,
2738
+ meta: { url, method, jwtAttack: jwtRes.attack, jwtAlg: jwtRes.alg, jwtDetail: jwtRes.detail }
2739
+ });
2740
+ }
2741
+ }
2433
2742
  });
2434
2743
  fastify.addHook("onResponse", (req, reply) => {
2435
2744
  const ip = client.config.getIp(req);
@@ -2585,23 +2894,40 @@ function createExpressMiddleware2(client) {
2585
2894
  } catch {
2586
2895
  }
2587
2896
  }
2897
+ const startTs = process.hrtime.bigint();
2898
+ let resSize = 0;
2899
+ let resFieldCount = 0;
2900
+ const origJson = res.json.bind(res);
2901
+ res.json = function anomiraJson(body) {
2902
+ try {
2903
+ if (body !== null && typeof body === "object" && !Array.isArray(body)) {
2904
+ resFieldCount = Object.keys(body).length;
2905
+ }
2906
+ const serialised = JSON.stringify(body);
2907
+ resSize = Buffer.byteLength(serialised, "utf8");
2908
+ } catch {
2909
+ }
2910
+ return origJson(body);
2911
+ };
2912
+ const origSend = res.send.bind(res);
2913
+ res.send = function anomiraSend(body) {
2914
+ if (resSize === 0) {
2915
+ if (typeof body === "string") resSize = Buffer.byteLength(body, "utf8");
2916
+ else if (Buffer.isBuffer(body)) resSize = body.length;
2917
+ }
2918
+ return origSend(body);
2919
+ };
2588
2920
  const onFinish = () => {
2589
2921
  res.off("finish", onFinish);
2590
2922
  const { statusCode } = res;
2923
+ const resTimeMs = Math.round(Number(process.hrtime.bigint() - startTs) / 1e6);
2924
+ const resMeta = { url, method, statusCode, resTimeMs, resSize, resFieldCount };
2591
2925
  if (client.config.detect.rateAbuse && statusCode === 429) {
2592
- client.track(EventName.RATE_LIMIT, {
2593
- ip,
2594
- userId,
2595
- meta: { url, method, statusCode }
2596
- });
2926
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: resMeta });
2597
2927
  }
2598
2928
  if (client.config.detect.bruteForce && statusCode === 401) {
2599
2929
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2600
- client.track(EventName.LOGIN_FAILED, {
2601
- ip,
2602
- userId,
2603
- meta: { url, method, statusCode }
2604
- });
2930
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: resMeta });
2605
2931
  }
2606
2932
  }
2607
2933
  if (client.config.detect.scanDetection && statusCode === 404) {
@@ -2610,14 +2936,14 @@ function createExpressMiddleware2(client) {
2610
2936
  client.track(EventName.SCAN_DETECTED, {
2611
2937
  ip,
2612
2938
  userId,
2613
- meta: { url, method, userAgent: ua }
2939
+ meta: { ...resMeta, userAgent: ua }
2614
2940
  });
2615
2941
  }
2616
2942
  }
2617
2943
  if (client.config.detect.geoVelocity && statusCode === 200 && /\/(login|signin|auth|token)/i.test(url) && method === "POST") {
2618
2944
  const resolvedUserId = client.config.getUserId(req);
2619
2945
  if (resolvedUserId) {
2620
- void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
2946
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: resMeta });
2621
2947
  }
2622
2948
  }
2623
2949
  };
@@ -2657,30 +2983,52 @@ function createFastifyPlugin2(client) {
2657
2983
  }
2658
2984
  }
2659
2985
  });
2986
+ fastify.addHook("onSend", async (_req, _reply, payload) => {
2987
+ try {
2988
+ if (typeof payload === "string") {
2989
+ _req._anomiraResSize = Buffer.byteLength(payload, "utf8");
2990
+ try {
2991
+ const parsed = JSON.parse(payload);
2992
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2993
+ _req._anomiraResFields = Object.keys(parsed).length;
2994
+ }
2995
+ } catch {
2996
+ }
2997
+ } else if (Buffer.isBuffer(payload)) {
2998
+ _req._anomiraResSize = payload.length;
2999
+ }
3000
+ } catch {
3001
+ }
3002
+ return payload;
3003
+ });
2660
3004
  fastify.addHook("onResponse", async (req, reply) => {
2661
3005
  const ip = client.config.getIp(req);
2662
3006
  const userId = client.config.getUserId(req);
2663
3007
  const url = req.url;
2664
3008
  const method = req.method.toUpperCase();
2665
3009
  const statusCode = reply.statusCode;
3010
+ const resTimeMs = Math.round(reply.elapsedTime ?? 0);
3011
+ const resSize = req._anomiraResSize ?? 0;
3012
+ const resFieldCount = req._anomiraResFields ?? 0;
3013
+ const resMeta = { url, method, statusCode, resTimeMs, resSize, resFieldCount };
2666
3014
  if (client.config.detect.rateAbuse && statusCode === 429) {
2667
- client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode } });
3015
+ client.track(EventName.RATE_LIMIT, { ip, userId, meta: resMeta });
2668
3016
  }
2669
3017
  if (client.config.detect.bruteForce && statusCode === 401) {
2670
3018
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2671
- client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode } });
3019
+ client.track(EventName.LOGIN_FAILED, { ip, userId, meta: resMeta });
2672
3020
  }
2673
3021
  }
2674
3022
  if (client.config.detect.scanDetection && statusCode === 404) {
2675
3023
  const ua = req.headers["user-agent"] ?? "";
2676
3024
  if (!ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua)) {
2677
- client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
3025
+ client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { ...resMeta, userAgent: ua } });
2678
3026
  }
2679
3027
  }
2680
3028
  if (client.config.detect.geoVelocity && statusCode === 200 && method === "POST" && /\/(login|signin|auth|token)/i.test(url)) {
2681
3029
  const resolvedUserId = client.config.getUserId(req);
2682
3030
  if (resolvedUserId) {
2683
- void client.trackLogin({ ip, userId: resolvedUserId, meta: { url } });
3031
+ void client.trackLogin({ ip, userId: resolvedUserId, meta: resMeta });
2684
3032
  }
2685
3033
  }
2686
3034
  });