@anomira/node-sdk 0.2.2 → 0.2.3

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.js CHANGED
@@ -620,7 +620,7 @@ function detectHoneypotType(path) {
620
620
  function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
621
621
  switch (type) {
622
622
  case "env_file":
623
- return makeEnvFile(canaryToken, callbackBase, orgId);
623
+ return makeEnvFile(canaryToken);
624
624
  case "aws_credentials":
625
625
  return makeAwsCredentials(canaryToken);
626
626
  case "git_config":
@@ -641,14 +641,16 @@ function generateHoneypotResponse(type, canaryToken, callbackBase, orgId) {
641
641
  return makeGeneric(canaryToken);
642
642
  }
643
643
  }
644
- function makeEnvFile(canaryToken, callbackBase, orgId) {
644
+ function makeEnvFile(canaryToken, _callbackBase, _orgId) {
645
645
  const jwtSecret = genHex(32);
646
646
  const dbPassword = genAlphanumeric(20);
647
647
  const redisPassword = genAlphanumeric(16);
648
648
  const apiKey = genHex(32);
649
- const webhookUrl = `${callbackBase}/v1/canary/${orgId}/${canaryToken}`;
650
649
  const dnsDb = `db.${canaryToken}.srv.anomira.io`;
651
650
  const dnsCache = `cache.${canaryToken}.srv.anomira.io`;
651
+ const dnsMonitoring = `hooks.monitoring.${canaryToken}.srv.anomira.io`;
652
+ const dnsAlerts = `hooks.alerts.${canaryToken}.srv.anomira.io`;
653
+ const dnsDeploy = `deploy.internal.${canaryToken}.srv.anomira.io`;
652
654
  const fakeJwt = generateCanaryJwt(canaryToken, jwtSecret);
653
655
  const body = `# Production Environment Configuration
654
656
  # Last updated: ${new Date(Date.now() - 7 * 864e5).toISOString().split("T")[0]}
@@ -679,9 +681,9 @@ AWS_REGION=eu-west-1
679
681
  AWS_S3_BUCKET=app-production-${genHex(4)}
680
682
 
681
683
  # \u2500\u2500 Monitoring / Webhooks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
682
- MONITORING_WEBHOOK=${webhookUrl}
683
- ALERT_WEBHOOK=${webhookUrl}
684
- DEPLOY_HOOK=${callbackBase}/v1/canary/${orgId}/${canaryToken}
684
+ MONITORING_WEBHOOK=https://${dnsMonitoring}/v1/events/ingest
685
+ ALERT_WEBHOOK=https://${dnsAlerts}/services/${genHex(7).toUpperCase()}/notify
686
+ DEPLOY_HOOK=https://${dnsDeploy}/hooks/deploy/${genHex(6)}
685
687
 
686
688
  # \u2500\u2500 External APIs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
687
689
  INTERNAL_API_KEY=${apiKey}
@@ -1093,7 +1095,8 @@ function makeGeneric(canaryToken) {
1093
1095
  }
1094
1096
  function generateCanaryJwt(canaryToken, secret) {
1095
1097
  const now = Math.floor(Date.now() / 1e3);
1096
- const header = { alg: "HS256", typ: "JWT", kid: `canary-${canaryToken.slice(0, 12)}` };
1098
+ const kid = `${canaryToken.slice(0, 8)}-${canaryToken.slice(8, 12)}-4${canaryToken.slice(13, 16)}-${canaryToken.slice(16, 20)}-${canaryToken.slice(20, 32)}`;
1099
+ const header = { alg: "HS256", typ: "JWT", kid };
1097
1100
  const payload = {
1098
1101
  sub: "svc_internal_7482",
1099
1102
  name: "service-account",
@@ -1103,7 +1106,8 @@ function generateCanaryJwt(canaryToken, secret) {
1103
1106
  // issued 48h ago (realistic stale credential)
1104
1107
  exp: now - 86400,
1105
1108
  // expired 24h ago
1106
- jti: `canary-${canaryToken}`
1109
+ jti: canaryToken
1110
+ // raw hex — no "canary-" prefix to leak purpose
1107
1111
  };
1108
1112
  const h = Buffer.from(JSON.stringify(header)).toString("base64url");
1109
1113
  const p = Buffer.from(JSON.stringify(payload)).toString("base64url");
@@ -1765,6 +1769,12 @@ function defaultGetUserId(req) {
1765
1769
  );
1766
1770
  const jwtId = pickId(payload);
1767
1771
  if (jwtId) return jwtId;
1772
+ for (const val of Object.values(payload)) {
1773
+ if (val && typeof val === "object" && !Array.isArray(val)) {
1774
+ const nested = pickId(val);
1775
+ if (nested) return nested;
1776
+ }
1777
+ }
1768
1778
  }
1769
1779
  } catch {
1770
1780
  }
@@ -1787,15 +1797,21 @@ function normalizeIp(raw) {
1787
1797
  function defaultGetIp(req) {
1788
1798
  const r = req;
1789
1799
  const fwd = r["headers"];
1790
- const xff = fwd?.["x-forwarded-for"];
1791
- if (xff) {
1792
- const first = Array.isArray(xff) ? xff[0] : xff.split(",")[0];
1793
- if (first) return normalizeIp(first.trim());
1800
+ function firstHdr(name) {
1801
+ const v = fwd?.[name];
1802
+ if (!v) return void 0;
1803
+ const s = (Array.isArray(v) ? v[0] : v.split(",")[0])?.trim();
1804
+ return s || void 0;
1794
1805
  }
1795
- const xri = fwd?.["x-real-ip"];
1796
- if (typeof xri === "string") return normalizeIp(xri.trim());
1797
- const socket = r["socket"];
1798
- return normalizeIp(socket?.remoteAddress ?? "0.0.0.0");
1806
+ const ip = firstHdr("cf-connecting-ip") ?? // Cloudflare
1807
+ firstHdr("true-client-ip") ?? // Cloudflare Enterprise / Akamai
1808
+ firstHdr("x-forwarded-for") ?? // Nginx, AWS ALB, GCP LB, most proxies
1809
+ firstHdr("x-real-ip") ?? // Nginx (single-IP alternative to XFF)
1810
+ firstHdr("fastly-client-ip") ?? // Fastly CDN
1811
+ firstHdr("x-client-ip") ?? // Generic reverse proxies
1812
+ firstHdr("x-cluster-client-ip") ?? // Cluster / k8s ingress
1813
+ r["socket"]?.remoteAddress ?? "0.0.0.0";
1814
+ return normalizeIp(ip);
1799
1815
  }
1800
1816
  function sendFakeResponse(res, fakeResponse) {
1801
1817
  const allHeaders = {
@@ -1818,10 +1834,29 @@ function sendFakeResponse(res, fakeResponse) {
1818
1834
  res["end"](fakeResponse.body);
1819
1835
  }
1820
1836
  }
1837
+ function isLoopbackIp(ip) {
1838
+ return ip === "127.0.0.1" || ip === "0.0.0.0" || ip === "::1";
1839
+ }
1821
1840
  function createExpressMiddleware(client) {
1841
+ let ipCheckCount = 0;
1842
+ let ipLoopCount = 0;
1843
+ let ipWarnFired = false;
1844
+ let userIdCheckCount = 0;
1845
+ let userIdMissCount = 0;
1846
+ let userIdWarnFired = false;
1822
1847
  return async function sentinelMiddleware(req, res, next) {
1823
1848
  const startMs = Date.now();
1824
1849
  const ip = client.config.getIp(req);
1850
+ if (!ipWarnFired && ipCheckCount < 20) {
1851
+ ipCheckCount++;
1852
+ if (isLoopbackIp(ip)) ipLoopCount++;
1853
+ if (ipCheckCount === 20 && ipLoopCount >= 16) {
1854
+ ipWarnFired = true;
1855
+ console.warn(
1856
+ "[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Express fix: app.set('trust proxy', 1);\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
1857
+ );
1858
+ }
1859
+ }
1825
1860
  if (client.isBlocked(ip)) {
1826
1861
  const method_ = req["method"]?.toUpperCase() ?? "GET";
1827
1862
  const url_ = req["originalUrl"] ?? req["url"] ?? "/";
@@ -1878,12 +1913,12 @@ function createExpressMiddleware(client) {
1878
1913
  const b64p = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
1879
1914
  const pay = JSON.parse(Buffer.from(b64p + "==", "base64").toString());
1880
1915
  const jti = pay["jti"] ?? "";
1881
- const kid = hdr["kid"] ?? "";
1882
- if (jti.startsWith("canary-") && client["canaryTokenCache"].has(jti.slice(7)) || kid.startsWith("canary-") && client["canaryTokenCache"].has(kid.slice(7))) {
1916
+ const cache = client["canaryTokenCache"];
1917
+ if (jti && cache.has(jti)) {
1883
1918
  client.track("http.canary.triggered", {
1884
1919
  ip,
1885
1920
  userId,
1886
- meta: { endpoint: url, method, token: jti.slice(7, 23), source: "canary_jwt" }
1921
+ meta: { endpoint: url, method, token: jti.slice(0, 16), source: "canary_jwt" }
1887
1922
  });
1888
1923
  }
1889
1924
  }
@@ -1971,13 +2006,24 @@ function createExpressMiddleware(client) {
1971
2006
  }
1972
2007
  }
1973
2008
  const onFinish = () => {
2009
+ const lateUserId = client.config.getUserId(req);
2010
+ if (!userIdWarnFired && userIdCheckCount < 20) {
2011
+ userIdCheckCount++;
2012
+ if (!lateUserId) userIdMissCount++;
2013
+ if (userIdCheckCount === 20 && userIdMissCount >= 18) {
2014
+ userIdWarnFired = true;
2015
+ console.warn(
2016
+ "[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / express-jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => req.user?.id })"
2017
+ );
2018
+ }
2019
+ }
1974
2020
  const status = res["statusCode"] ?? 0;
1975
2021
  const latencyMs = Date.now() - startMs;
1976
2022
  const getHeader = res["getHeader"];
1977
2023
  const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
1978
2024
  client.track(EventName.REQUEST, {
1979
2025
  ip,
1980
- userId,
2026
+ userId: lateUserId,
1981
2027
  meta: {
1982
2028
  method,
1983
2029
  endpoint: url,
@@ -1988,14 +2034,12 @@ function createExpressMiddleware(client) {
1988
2034
  _fp: fingerprint.score,
1989
2035
  _fpSig: fingerprint.signals.join(","),
1990
2036
  ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
1991
- // HTTP/2 SETTINGS from client connection preface (when Node.js is TLS endpoint)
1992
2037
  ...h2settings ? {
1993
2038
  _h2Window: h2settings.initialWindowSize,
1994
2039
  _h2HdrTable: h2settings.headerTableSize,
1995
2040
  _h2Score: h2settings.score,
1996
2041
  _h2Signals: h2settings.signals.join(",")
1997
2042
  } : {},
1998
- // JA3/JA4 from upstream proxy (Nginx with ngx_ssl_fingerprint_module)
1999
2043
  ...upstreamTls.ja3 ? { _ja3: upstreamTls.ja3 } : {},
2000
2044
  ...upstreamTls.ja4 ? { _ja4: upstreamTls.ja4 } : {},
2001
2045
  ...agentInfo.isAgent ? {
@@ -2012,7 +2056,7 @@ function createExpressMiddleware(client) {
2012
2056
  if (agentInfo.isAgent) {
2013
2057
  client.track("http.agent_detected", {
2014
2058
  ip,
2015
- userId,
2059
+ userId: lateUserId,
2016
2060
  meta: {
2017
2061
  endpoint: url,
2018
2062
  method,
@@ -2028,17 +2072,17 @@ function createExpressMiddleware(client) {
2028
2072
  });
2029
2073
  }
2030
2074
  if (client.config.detect.rateAbuse && status === 429) {
2031
- client.track(EventName.RATE_LIMIT, { ip, userId, meta: { url, method, statusCode: status } });
2075
+ client.track(EventName.RATE_LIMIT, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
2032
2076
  }
2033
2077
  if (client.config.detect.bruteForce && status === 401) {
2034
2078
  if (/\/(login|signin|auth|token|session)/i.test(url)) {
2035
- client.track(EventName.LOGIN_FAILED, { ip, userId, meta: { url, method, statusCode: status } });
2079
+ client.track(EventName.LOGIN_FAILED, { ip, userId: lateUserId, meta: { url, method, statusCode: status } });
2036
2080
  }
2037
2081
  }
2038
2082
  if (client.config.detect.scanDetection && status === 404) {
2039
2083
  const looksLikeScanner = !ua || /curl|wget|python|go-http|nuclei|sqlmap|nikto/i.test(ua);
2040
2084
  if (looksLikeScanner) {
2041
- client.track(EventName.SCAN_DETECTED, { ip, userId, meta: { url, method, userAgent: ua } });
2085
+ client.track(EventName.SCAN_DETECTED, { ip, userId: lateUserId, meta: { url, method, userAgent: ua } });
2042
2086
  }
2043
2087
  }
2044
2088
  cleanup();
@@ -2051,9 +2095,25 @@ function createExpressMiddleware(client) {
2051
2095
  };
2052
2096
  }
2053
2097
  function createFastifyPlugin(client) {
2098
+ let ipCheckCount = 0;
2099
+ let ipLoopCount = 0;
2100
+ let ipWarnFired = false;
2101
+ let userIdCheckCount = 0;
2102
+ let userIdMissCount = 0;
2103
+ let userIdWarnFired = false;
2054
2104
  return async function sentinelFastifyPlugin(fastify) {
2055
2105
  fastify.addHook("onRequest", (req, reply) => {
2056
2106
  const ip = client.config.getIp(req);
2107
+ if (!ipWarnFired && ipCheckCount < 20) {
2108
+ ipCheckCount++;
2109
+ if (isLoopbackIp(ip)) ipLoopCount++;
2110
+ if (ipCheckCount === 20 && ipLoopCount >= 16) {
2111
+ ipWarnFired = true;
2112
+ console.warn(
2113
+ "[Anomira] WARNING: client IP not captured on 80%+ of requests.\n Your app is likely behind a reverse proxy (Nginx, Cloudflare, AWS ALB)\n that is not forwarding client IP headers. Alerts will have no IP attribution.\n\n Nginx fix: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Real-IP $remote_addr;\n Fastify fix: Fastify({ trustProxy: true })\n Docs: https://docs.anomira.io/sdk/ip-capture"
2114
+ );
2115
+ }
2116
+ }
2057
2117
  const url_ = req["url"] ?? "/";
2058
2118
  const method_ = req["method"]?.toUpperCase() ?? "GET";
2059
2119
  const hdrs_ = req["headers"];
@@ -2125,6 +2185,16 @@ function createFastifyPlugin(client) {
2125
2185
  const method = req["method"]?.toUpperCase() ?? "GET";
2126
2186
  const userId = client.config.getUserId(req);
2127
2187
  const status = reply["statusCode"] ?? 0;
2188
+ if (!userIdWarnFired && userIdCheckCount < 20) {
2189
+ userIdCheckCount++;
2190
+ if (!userId) userIdMissCount++;
2191
+ if (userIdCheckCount === 20 && userIdMissCount >= 18) {
2192
+ userIdWarnFired = true;
2193
+ console.warn(
2194
+ "[Anomira] WARNING: userId not captured on 90%+ of requests.\n EWS Evidence Package, geo-velocity, and account takeover detection\n silently stop working without it. The SDK tried 5 auto-detection tiers\n (Passport / @fastify/jwt, req.auth, direct req.userId, session, JWT Bearer)\n \u2014 none matched your auth setup.\n\n Fix: pass a getUserId resolver that matches your auth middleware:\n new Anomira({ ..., getUserId: (req) => (req as any).user?.id })"
2195
+ );
2196
+ }
2197
+ }
2128
2198
  const headers = req["headers"];
2129
2199
  const ua = (typeof headers?.["user-agent"] === "string" ? headers["user-agent"] : "") ?? "";
2130
2200
  const latencyMs = reply["elapsedTime"] ?? 0;
@@ -2208,6 +2278,11 @@ function createExpressMiddleware2(client) {
2208
2278
  const ip = client.config.getIp(req);
2209
2279
  const userId = client.config.getUserId(req);
2210
2280
  const { method, originalUrl: url } = req;
2281
+ if (client.isBlocked(ip)) {
2282
+ client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
2283
+ res.status(403).json({ error: "Forbidden" });
2284
+ return;
2285
+ }
2211
2286
  if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
2212
2287
  client.track(EventName.PATH_TRAVERSAL, {
2213
2288
  ip,
@@ -2272,11 +2347,15 @@ function createExpressMiddleware2(client) {
2272
2347
  // src/middleware/fastify.ts
2273
2348
  function createFastifyPlugin2(client) {
2274
2349
  return async function sentinelPlugin(fastify) {
2275
- fastify.addHook("onRequest", async (req, _reply) => {
2350
+ fastify.addHook("onRequest", async (req, reply) => {
2276
2351
  const ip = client.config.getIp(req);
2277
2352
  const userId = client.config.getUserId(req);
2278
2353
  const url = req.url;
2279
2354
  const method = req.method.toUpperCase();
2355
+ if (client.isBlocked(ip)) {
2356
+ client.reportBlockedHit(ip, { method, url, userAgent: req.headers["user-agent"] ?? "" });
2357
+ return reply.code(403).send({ error: "Forbidden" });
2358
+ }
2280
2359
  if (client.config.detect.pathTraversal && (url.includes("../") || url.includes("..%2F"))) {
2281
2360
  client.track(EventName.PATH_TRAVERSAL, { ip, userId, meta: { url, method } });
2282
2361
  }